mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344.
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
// Why: `git worktree remove` deletes the whole checkout (usually a multi-GB node_modules) inside the
|
|
// remove IPC, so the UI sat on a spinner for 8-35s. Renaming the directory into a sibling trash root
|
|
// is a metadata operation, and the recursive delete then runs after the IPC has already returned.
|
|
|
|
import { randomBytes } from 'node:crypto'
|
|
import { lstat, mkdir, readdir, rename, rmdir } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { removeHostTree } from './host-tree-removal'
|
|
import { isFolderRepo } from '../shared/repo-kind'
|
|
import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic'
|
|
import type { GlobalSettings } from '../shared/global-settings-types'
|
|
import type { Repo } from '../shared/repo-types'
|
|
import { parseWslPath } from './wsl'
|
|
|
|
export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash'
|
|
|
|
// `<epoch-ms>-<nonce>`: the nonce keeps concurrent removals of same-named worktrees from colliding.
|
|
const TRASH_ENTRY_PATTERN = /^wt-\d+-[0-9a-f]{8}$/
|
|
|
|
// Why: the sweep must stay cheap on a workspace root holding many repo containers.
|
|
const TRASH_SWEEP_MAX_CONTAINERS = 200
|
|
|
|
/** Trash root for a worktree: a hidden sibling, so the rename always stays on one volume. */
|
|
export function getWorktreeTrashRoot(worktreePath: string): string {
|
|
return join(dirname(worktreePath), WORKTREE_TRASH_DIR_NAME)
|
|
}
|
|
|
|
export function isWorktreeTrashEntryName(entryName: string): boolean {
|
|
return TRASH_ENTRY_PATTERN.test(entryName)
|
|
}
|
|
|
|
/**
|
|
* Move a worktree directory aside so the caller can return before it is deleted.
|
|
* Returns the trash path, or `undefined` when the rename is unavailable (a different
|
|
* volume, or Windows open handles) and the caller must delete in place instead.
|
|
*/
|
|
export async function moveWorktreeDirectoryToTrash(
|
|
worktreePath: string
|
|
): Promise<string | undefined> {
|
|
const trashRoot = getWorktreeTrashRoot(worktreePath)
|
|
const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`)
|
|
try {
|
|
await mkdir(trashRoot, { recursive: true })
|
|
const trashRootStat = await lstat(trashRoot)
|
|
if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing non-directory worktree trash root: ${trashRoot}`)
|
|
}
|
|
await rename(worktreePath, trashPath)
|
|
return trashPath
|
|
} catch (error) {
|
|
console.warn(
|
|
`[worktrees] Deferred deletion unavailable for ${worktreePath}; deleting in place`,
|
|
error
|
|
)
|
|
// Leave no empty trash root behind when the rename never happened; rmdir keeps queued entries.
|
|
await rmdir(trashRoot).catch(() => {})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Undo a trash rename so a failed registration cleanup leaves the worktree exactly as it was. */
|
|
export async function restoreWorktreeDirectoryFromTrash(
|
|
trashPath: string,
|
|
worktreePath: string
|
|
): Promise<boolean> {
|
|
try {
|
|
await rename(trashPath, worktreePath)
|
|
return true
|
|
} catch (error) {
|
|
console.warn(`[worktrees] Failed to restore ${worktreePath} from ${trashPath}`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Why serialized: one background delete at a time keeps a burst of removals from saturating disk I/O
|
|
// while the user keeps working.
|
|
let queuedTrashDeletions: Promise<void> = Promise.resolve()
|
|
|
|
export function scheduleWorktreeTrashDeletion(trashPath: string): void {
|
|
queuedTrashDeletions = queuedTrashDeletions.then(async () => {
|
|
try {
|
|
await removeHostTree(trashPath)
|
|
} catch (error) {
|
|
// Why only a warning: the directory is already invisible to the user, and the startup sweep retries it.
|
|
console.warn(`[worktrees] Failed to delete trashed worktree at ${trashPath}`, error)
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Test/shutdown hook: resolves once every queued background deletion has settled. */
|
|
export function whenWorktreeTrashDeletionsSettled(): Promise<void> {
|
|
return queuedTrashDeletions
|
|
}
|
|
|
|
/**
|
|
* Delete trash entries left behind by a previous run (a crash or a kill during background deletion).
|
|
* Only entries matching the generated name pattern inside a trash root are removed.
|
|
*/
|
|
export async function sweepStaleWorktreeTrash(
|
|
workspaceRoots: readonly string[]
|
|
): Promise<{ removed: number }> {
|
|
let removed = 0
|
|
for (const trashRoot of await collectExistingTrashRoots(workspaceRoots)) {
|
|
let entries: string[]
|
|
try {
|
|
const trashRootStat = await lstat(trashRoot)
|
|
if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) {
|
|
continue
|
|
}
|
|
entries = await readdir(trashRoot)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const entry of entries) {
|
|
if (!isWorktreeTrashEntryName(entry)) {
|
|
continue
|
|
}
|
|
try {
|
|
await removeHostTree(join(trashRoot, entry))
|
|
removed += 1
|
|
} catch (error) {
|
|
console.warn(
|
|
`[worktrees] Failed to sweep leftover worktree at ${trashRoot}/${entry}`,
|
|
error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (removed > 0) {
|
|
console.log(`[worktrees] Swept ${removed} leftover worktree director(ies) from a previous run`)
|
|
}
|
|
return { removed }
|
|
}
|
|
|
|
/** Trash roots live beside worktrees, so they sit at the workspace root (flat) or one level in (nested). */
|
|
async function collectExistingTrashRoots(workspaceRoots: readonly string[]): Promise<string[]> {
|
|
const trashRoots = new Set<string>()
|
|
for (const workspaceRoot of new Set(workspaceRoots)) {
|
|
trashRoots.add(join(workspaceRoot, WORKTREE_TRASH_DIR_NAME))
|
|
let containers: string[] = []
|
|
try {
|
|
containers = (await readdir(workspaceRoot, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory() && entry.name !== WORKTREE_TRASH_DIR_NAME)
|
|
.slice(0, TRASH_SWEEP_MAX_CONTAINERS)
|
|
.map((entry) => entry.name)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const container of containers) {
|
|
trashRoots.add(join(workspaceRoot, container, WORKTREE_TRASH_DIR_NAME))
|
|
}
|
|
}
|
|
return [...trashRoots]
|
|
}
|
|
|
|
/** Workspace roots of local git repos — the only places Orca creates worktree trash. */
|
|
export function collectWorktreeTrashSweepRoots(
|
|
repos: readonly Repo[],
|
|
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>
|
|
): string[] {
|
|
const roots = new Set<string>()
|
|
for (const repo of repos) {
|
|
if (repo.connectionId || isFolderRepo(repo) || parseWslPath(repo.path)) {
|
|
continue
|
|
}
|
|
try {
|
|
const workspaceRoot = computeWorkspaceRoot(repo.path, getWorktreePathSettings(repo, settings))
|
|
if (!parseWslPath(workspaceRoot)) {
|
|
roots.add(workspaceRoot)
|
|
}
|
|
} catch {
|
|
// A repo with an unusable configured base path simply has no trash root to sweep.
|
|
}
|
|
}
|
|
return [...roots]
|
|
}
|