Files
orca/src/shared/git-common-directory.ts
T
Brennan BensonandOrca Worker f0eb0949d0 fix(windows): delete again succeeds after a held file is released (#34058)
* fix(windows): stop recursive deletes from retry-storming when a file is held open

Node's async recursive rm retries every nested entry separately and orphans
sibling walkers when it rejects, so deleting a workspace holding a live
browser profile multiplied filesystem work per directory level until the
main process hung. Delete Windows host trees with a walker that retries
whole passes from one shared budget and leaves nothing running on failure.

Also bound and log detached worktree removal, and fail a file transaction
whose proper-lockfile lock is compromised instead of throwing from its timer.

* fix(windows): retry a locked delete as whole passes instead of a custom walker

Replace the hand-rolled tree walker with Node's own recursive rm and no
per-entry retries. Async rm gives each nested entry its own retry counter and
never stops siblings, which is what multiplied the work; with retries off each
pass is one bounded walk, and a transient Windows lock repeats the whole pass
from the existing 250/500/1000/2000 ms budget, checking the job's signal
between passes.

A failed delete now names the file another program holds open instead of
Node's EBUSY/EPERM text. Drop the 30-minute background deadline. The lost-lock
error no longer embeds the library's message and says the write may or may not
have been saved.

* test: cover bounded deletion failure and recovery contracts

* test: prune unchanged cleanup coverage

* fix(worktrees): give truthful failed-delete cleanup advice

* test: document fake-clock retry adapter

* fix(windows): preserve checkout ownership until files are removed

* fix: preserve worktree ownership checks across metadata aliases

* test: make checkout retry clock completion explicit

* Keep failed Windows workspace deletions retryable

* test: correct removal fixture imports and path formatting

* fix: retain failed removal until a retry is accepted

* test: identify the mocked backoff clock

* test: preserve failed removal records and recreate marker fixtures

* test(worktrees): preserve exact checkout bytes in refusal contract

* test(worktrees): exercise current native deletion contracts

* test(windows): prove long-path deletion through native checkout removal

* test(windows): preserve real Git runner exports in removal fixture

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-27 12:31:10 -07:00

82 lines
2.6 KiB
TypeScript

import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { waitForPromiseWithSignal } from './abort-signal-reason'
import { resolveGitMetadataPath, type GitMetadataPathOptions } from './git-metadata-path'
import { parseGitdirMarkerPayload } from './gitdir-marker-payload'
export type GitAdminReadOptions = GitMetadataPathOptions & { signal?: AbortSignal }
type GitLayoutStat = { isDirectory(): boolean; isFile(): boolean }
export type GitCommonDirectoryReader = {
statPath(path: string): Promise<GitLayoutStat>
readPath(path: string): Promise<string | null>
join(...paths: string[]): string
resolvePath(base: string, pointer: string): string | null
parseMarker(content: string): string | null
isMissing(error: unknown): boolean
}
export function isMissingGitAdminEntry(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
)
}
export async function readGitAdminFile(
filePath: string,
signal?: AbortSignal
): Promise<string | null> {
signal?.throwIfAborted()
try {
return await readFile(filePath, { encoding: 'utf8', signal })
} catch (error) {
if (isMissingGitAdminEntry(error)) {
return null
}
throw error
}
}
/** Read the owning host's Git layout without starting a subprocess. */
export async function resolveGitCommonDirectory(
repoPath: string,
options: GitAdminReadOptions = {},
reader: GitCommonDirectoryReader = {
statPath: (file) => waitForPromiseWithSignal(stat(file), options.signal),
readPath: (file) => readGitAdminFile(file, options.signal),
join: path.join,
resolvePath: (base, pointer) => resolveGitMetadataPath(base, pointer, options),
parseMarker: parseGitdirMarkerPayload,
isMissing: isMissingGitAdminEntry
}
): Promise<string | null> {
const dotGit = reader.join(repoPath, '.git')
let metadata: GitLayoutStat | undefined
let gitDir: string | null = null
try {
metadata = await reader.statPath(dotGit)
} catch (error) {
if (!reader.isMissing(error)) {
throw error
}
if ((await reader.readPath(reader.join(repoPath, 'HEAD'))) !== null) {
gitDir = repoPath
}
}
if (metadata?.isDirectory()) {
gitDir = dotGit
} else if (metadata?.isFile()) {
const marker = reader.parseMarker((await reader.readPath(dotGit)) ?? '')
if (marker) {
gitDir = reader.resolvePath(repoPath, marker)
}
}
if (!gitDir) {
return null
}
const commonDir = (await reader.readPath(reader.join(gitDir, 'commondir')))?.trim()
return commonDir ? reader.resolvePath(gitDir, commonDir) : gitDir
}