From 6e2a4a824d0a22cad9beac609cd1cd898900ad86 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:15 -0700 Subject: [PATCH] fix(worktrees): stop surfacing prunable git worktrees as live workspaces (#8409) * fix(worktrees): stop surfacing prunable git worktrees as live workspaces A worktree still registered in git but whose directory was deleted (git's `prunable` state) was enumerated as a normal workspace, producing repeated pty:spawn DaemonProtocolError / fs:readDir ENOENT loops and a blank pane. - Parse the `prunable` porcelain field (Git >= 2.36) in both the main and relay worktree-list parsers. - For Git < 2.36 (no `prunable` field), probe each linked worktree path for existence on the fallback line-block path, skipping locked registrations to mirror git's own prunable rules. - Omit prunable worktrees from the detected-workspace enumeration only; removal/cleanup flows keep seeing them. - Extend the real-binary compatibility contract with the 2.36 `prunable` boundary. Fixes #8389 Claude-Session: https://claude.ai/code/session_018Rg1Bpq4GGwmz613hq6RSD * fix(worktrees): pin the prunable/locked porcelain annotations to their real Git 2.31 boundary The prunable and locked annotations landed in Git 2.31, five releases before `worktree list -z` (2.36); only -z defines the capability fallback boundary. Correct the compatibility contract so a future matrix entry in the 2.31-2.35 range passes, and reword the fallback comments: on 2.31-2.35 the annotations still parse and the existence probe is a backstop; only Git <2.31 relies on it outright. * fix(worktrees): omit prunable registrations from the Space scan A prunable registration has no directory to size or reclaim, so Space rendered it as a dead "Missing" row whose checkbox stayed disabled with no prune/remove affordance (reported on macOS after a reboot cleared /private/tmp under 16 registrations). Skip prunable entries in the scan, matching the workspace enumeration; removal flows list worktrees separately and still see them. --------- Co-authored-by: kaynan Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- docs/reference/git-compatibility.md | 2 +- src/main/git/remove-worktree.test.ts | 38 ++++++++++++ src/main/git/worktree-list-paths.test.ts | 38 ++++++++++++ src/main/git/worktree.test.ts | 37 +++++++++++- src/main/git/worktree.ts | 66 ++++++++++++++++++++- src/main/ipc/worktrees.test.ts | 39 ++++++++++++ src/main/ipc/worktrees.ts | 5 +- src/main/workspace-space-analysis.test.ts | 44 ++++++++++++++ src/main/workspace-space-analysis.ts | 9 ++- src/relay/git-handler-utils.test.ts | 32 ++++++++++ src/relay/git-handler-utils.ts | 10 ++++ src/relay/git-handler-worktree-list.test.ts | 49 +++++++++++++++ src/relay/git-handler-worktree-list.ts | 50 ++++++++++++++++ src/relay/git-handler.ts | 12 +++- src/shared/git-binary-compatibility.test.ts | 8 +++ src/shared/types.ts | 5 ++ 16 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 src/relay/git-handler-worktree-list.test.ts diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index c943bc9ce..b740982fe 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -35,7 +35,7 @@ authority. | Capability | Preferred behavior | Compatibility behavior | | ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` | +| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | | `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | | `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 1329ba775..121a82087 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1068,6 +1068,14 @@ describe('listWorktrees', () => { 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' } }) + // Why: the fallback probes each linked worktree path for existence; keep + // the paths "present" so this test stays about parser selection. + statMock.mockImplementation(async (targetPath: string) => { + if (String(targetPath).endsWith('sparse-checkout')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return {} + }) await expect(listWorktrees('/repo')).resolves.toEqual([ { @@ -1090,6 +1098,36 @@ describe('listWorktrees', () => { 'git worktree list --porcelain' ]) }) + + it('annotates missing linked worktrees as prunable via the line-block fallback', async () => { + // Why: Git <2.36 lacks the `prunable` porcelain field (issue #8389), so + // the fallback must probe each linked worktree path instead of treating a + // stale registration as a live workspace. + mockGitCommands({ + 'git worktree list --porcelain -z': { + error: Object.assign(new Error("unknown switch `z'"), { + stderr: "error: unknown switch `z'" + }) + }, + 'git worktree list --porcelain': { + stdout: + 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n' + + 'worktree /repo-locked\nHEAD aaa789\nbranch refs/heads/agent\nlocked agent session\n' + } + }) + // statMock default (beforeEach): every path is missing (ENOENT). + + const worktrees = await listWorktrees('/repo') + + expect(worktrees.find((worktree) => worktree.path === '/repo-feature')).toMatchObject({ + prunable: true + }) + // Locked registrations are shielded, mirroring git's own prunable rules; + // the main worktree is covered by the repo-level missing-path handling. + expect(worktrees.find((worktree) => worktree.path === '/repo-locked')?.prunable).toBeUndefined() + expect(worktrees.find((worktree) => worktree.path === '/repo')?.prunable).toBeUndefined() + }) }) describe('addSparseWorktree', () => { diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts index df83417e7..a248ddb75 100644 --- a/src/main/git/worktree-list-paths.test.ts +++ b/src/main/git/worktree-list-paths.test.ts @@ -66,6 +66,31 @@ async function createRepoWithLockedDeletedWorktree(): Promise<{ } } +async function createRepoWithPrunableWorktree(): Promise<{ + repoPath: string + worktreePath: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-prunable-')) + tempRoots.push(root) + const repoPath = path.join(root, 'repo') + const requestedWorktreePath = path.join(root, 'stale-worktree') + + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial']) + git(repoPath, ['worktree', 'add', '--quiet', '-b', 'feature/stale', requestedWorktreePath]) + + const worktreePath = await realpath(requestedWorktreePath) + await rm(worktreePath, { recursive: true, force: true }) + + return { + repoPath: await realpath(repoPath), + worktreePath + } +} + function branchExists(repoPath: string, branchName: string): boolean { try { git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]) @@ -102,6 +127,19 @@ describe('git worktree paths', () => { } ) + it('annotates a worktree whose directory was deleted as prunable', async () => { + // Why: an un-pruned registration with a deleted directory must not surface + // as a live workspace (issue #8389). + const { repoPath, worktreePath } = await createRepoWithPrunableWorktree() + + const worktrees = await listWorktrees(repoPath) + const stale = worktrees.find( + (worktree) => worktree.path.replaceAll('\\', '/') === worktreePath.replaceAll('\\', '/') + ) + + expect(stale).toMatchObject({ prunable: true }) + }) + it('preserves a locked worktree whose directory was deleted manually', async () => { const { repoPath, worktreePath } = await createRepoWithLockedDeletedWorktree() diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 035a7f774..258ecfa36 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -273,6 +273,38 @@ describe('parseWorktreeList', () => { }) }) + it('preserves a prunable marker and its reason', () => { + expect( + parseWorktreeList( + 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /stale\nHEAD def\nbranch refs/heads/feature\nprunable gitdir file points to non-existent location\n' + )[1] + ).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + + it('preserves a NUL-delimited prunable marker', () => { + const output = [ + 'worktree /repo', + 'HEAD abc', + 'branch refs/heads/main', + '', + 'worktree /stale', + 'HEAD def', + 'branch refs/heads/feature', + 'prunable gitdir file points to non-existent location', + '' + ].join('\0') + + expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + it('parses regular and bare worktree blocks from porcelain output', () => { const output = ` worktree /repo @@ -555,7 +587,10 @@ branch refs/heads/main-2 head: 'def456', branch: 'refs/heads/main-2', isBare: false, - isMainWorktree: false + isMainWorktree: false, + // The line-block fallback also probes linked worktree paths for + // existence (issue #8389), and this mocked path is absent on disk. + prunable: true } ]) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 97a731fff..8823c683b 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -81,6 +81,8 @@ type LocalBaseRefRefreshability = const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 +const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 + // Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't // stall its checkout writes for minutes (STA-1292) — a stuck create then fails // fast instead of spinning forever. Generous enough not to kill a legit large @@ -505,6 +507,8 @@ export function parseWorktreeList( let isSparse = false let locked = false let lockReason = '' + let prunable = false + let prunableReason = '' for (const line of lines) { if (line.startsWith('worktree ')) { @@ -521,6 +525,12 @@ export function parseWorktreeList( locked = true const rawReason = line.slice('locked'.length).trim() lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) + } else if (line === 'prunable' || line.startsWith('prunable ')) { + // Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring + // it surfaces the stale worktree as a live workspace (issue #8389). + prunable = true + const rawReason = line.slice('prunable'.length).trim() + prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) } } @@ -534,6 +544,8 @@ export function parseWorktreeList( ...(isSparse ? { isSparse } : {}), ...(locked ? { locked: true } : {}), ...(lockReason ? { lockReason } : {}), + ...(prunable ? { prunable: true } : {}), + ...(prunableReason ? { prunableReason } : {}), isMainWorktree: worktrees.length === 0 }) } @@ -603,12 +615,64 @@ async function readWorktreeList( cwd: repoPath, ...options }) - return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: this `-z`-unsupported fallback (Git <2.36) also serves Git <2.31, + // which emits no `prunable` annotation; probe each linked worktree path + // for existence instead of treating stale registrations as live. On Git + // 2.31–2.35 `parseWorktreeList` already set `prunable`, so the probe is a + // harmless backstop that skips those entries (issue #8389). + return annotatePrunableByExistence(normalized, repoPath, options) }, isUnsupportedWorktreeListZError ) } +async function annotatePrunableByExistence( + worktrees: GitWorktreeInfo[], + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const annotated = [...worktrees] + let nextIndex = 0 + + async function probeNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + // Git only marks linked worktrees prunable, and never locked ones (a + // lock shields the registration even when the directory is missing). The + // `locked` annotation is only parsed on Git >=2.31, so on older Git a + // locked+missing worktree cannot be shielded here. A missing main + // worktree is handled by the repo-level ENOENT paths. + if ( + !worktree || + worktree.isMainWorktree || + worktree.isBare || + worktree.locked || + worktree.prunable + ) { + continue + } + try { + await stat(translateWorktreePath(worktree.path, repoPath, options)) + } catch (err) { + if (getErrorCode(err) === 'ENOENT') { + annotated[index] = { ...worktree, prunable: true } + } + } + } + } + + const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => probeNext())) + return annotated +} + async function readTranslatedWorktreeGraph( repoPath: string, options: GitWorktreeExecOptions = {} diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index dab56b534..201a85c35 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6021,6 +6021,45 @@ describe('registerWorktreeHandlers', () => { }) }) + it('omits prunable worktrees from worktrees:listAll', async () => { + // Why: a prunable registration has no working directory (issue #8389), so + // surfacing it as a workspace yields repeated pty:spawn/fs:readDir + // failures and a blank pane. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/stale-wt', + head: 'def456', + branch: 'refs/heads/stale', + isBare: false, + prunable: true, + prunableReason: 'gitdir file points to non-existent location', + isMainWorktree: false + }, + { + path: '/workspace/live-wt', + head: 'fed789', + branch: 'refs/heads/live', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + const listed = (await handlers['worktrees:listAll'](null, undefined)) as { id: string }[] + const listedIds = listed.map((worktree) => worktree.id) + + expect(listedIds).toContain('repo-1::/workspace/live-wt') + expect(listedIds).not.toContain('repo-1::/workspace/stale-wt') + }) + it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => { const repos = Array.from({ length: 10 }, (_, index) => ({ id: `repo-${index}`, diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a7ced3b4c..9e7ff509b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -751,7 +751,10 @@ function buildDetectedGitWorktrees( const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - return dedupeWorktreesByPath(gitWorktrees).map((gitWorktree) => { + // Why: a prunable registration has no working directory (issue #8389); only + // this listing omits it — removal/cleanup flows list worktrees separately. + const liveWorktrees = gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + return dedupeWorktreesByPath(liveWorktrees).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index bfee52956..86af032ae 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -123,6 +123,50 @@ describe('analyzeWorkspaceSpace', () => { expect(result.reclaimableBytes).toBe(feature?.sizeBytes) }) + it('omits prunable worktree registrations from the Space scan', async () => { + // Why: a prunable registration has no directory to size or reclaim (issue + // #8389); before this filter it rendered as a dead "Missing" row whose + // checkbox stayed disabled with no prune/remove action. + const root = tempDir! + const mainPath = join(root, 'repo') + const stalePath = join(root, 'stale') + await mkdir(join(mainPath, 'src'), { recursive: true }) + await writeSizedFile(join(mainPath, 'src', 'main.ts'), 256) + + const repo: Repo = { + id: 'repo-1', + path: mainPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: mainPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: stalePath, + head: 'b', + branch: 'refs/heads/stale', + isBare: false, + prunable: true, + prunableReason: 'gitdir file points to non-existent location', + isMainWorktree: false + } + ]) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(result.worktreeCount).toBe(1) + expect(result.worktrees.find((row) => row.path === stalePath)).toBeUndefined() + expect(result.worktrees.find((row) => row.path === mainPath)?.status).toBe('ok') + expect(result.repos[0]?.unavailableWorktreeCount).toBe(0) + }) + it('reports scan progress as repos and worktrees are scanned', async () => { const root = tempDir! const repoPath = join(root, 'repo') diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index f035a426a..cc792202f 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -850,9 +850,12 @@ async function scanRepo( } } - const worktrees = listed.worktrees.map((gitWorktree) => - mergeForSpaceScan(repo, gitWorktree, store) - ) + // Why: a prunable registration has no directory to size or reclaim (issue + // #8389); it would only render a dead "Missing" row with no available + // action. Removal flows list worktrees separately and still see it. + const worktrees = listed.worktrees + .filter((gitWorktree) => !gitWorktree.prunable) + .map((gitWorktree) => mergeForSpaceScan(repo, gitWorktree, store)) reportProgress( progress, { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 211e4b205..e1c18a78d 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -43,6 +43,38 @@ describe('parseWorktreeList', () => { lockReason: '"literal\\nquote"' }) }) + + it('preserves a prunable marker and its reason', () => { + expect( + parseWorktreeList( + 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /stale\nHEAD def\nbranch refs/heads/feature\nprunable gitdir file points to non-existent location\n' + )[1] + ).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + + it('preserves a NUL-delimited prunable marker', () => { + const output = [ + 'worktree /repo', + 'HEAD abc', + 'branch refs/heads/main', + '', + 'worktree /stale', + 'HEAD def', + 'branch refs/heads/feature', + 'prunable gitdir file points to non-existent location', + '' + ].join('\0') + + expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) }) describe('isUnsupportedWorktreeListZError', () => { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 6fbb8ec4a..ab3bb61fa 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -152,6 +152,8 @@ export function parseWorktreeList( let isBare = false let locked = false let lockReason = '' + let prunable = false + let prunableReason = '' for (const line of lines) { if (line.startsWith('worktree ')) { @@ -166,6 +168,12 @@ export function parseWorktreeList( locked = true const rawReason = line.slice('locked'.length).trim() lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) + } else if (line === 'prunable' || line.startsWith('prunable ')) { + // Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring + // it surfaces the stale worktree as a live workspace (issue #8389). + prunable = true + const rawReason = line.slice('prunable'.length).trim() + prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) } } @@ -177,6 +185,8 @@ export function parseWorktreeList( isBare, ...(locked ? { locked: true } : {}), ...(lockReason ? { lockReason } : {}), + ...(prunable ? { prunable: true } : {}), + ...(prunableReason ? { prunableReason } : {}), isMainWorktree: worktrees.length === 0 }) } diff --git a/src/relay/git-handler-worktree-list.test.ts b/src/relay/git-handler-worktree-list.test.ts new file mode 100644 index 000000000..259693817 --- /dev/null +++ b/src/relay/git-handler-worktree-list.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' + +const tempRoots: string[] = [] + +async function createTempDir(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-relay-prunable-')) + tempRoots.push(root) + return root +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('annotatePrunableWorktreesByExistence', () => { + it('marks linked worktrees with missing directories as prunable', async () => { + const liveDir = await createTempDir() + const missingDir = path.join(liveDir, 'deleted-worktree') + + const annotated = await annotatePrunableWorktreesByExistence([ + { path: liveDir, isMainWorktree: true }, + { path: path.join(liveDir, 'also-missing-main'), isMainWorktree: true }, + { path: liveDir, isMainWorktree: false }, + { path: missingDir, isMainWorktree: false } + ]) + + expect(annotated[0]?.prunable).toBeUndefined() + // Git never marks the main worktree prunable; repo-level handling owns it. + expect(annotated[1]?.prunable).toBeUndefined() + expect(annotated[2]?.prunable).toBeUndefined() + expect(annotated[3]).toMatchObject({ path: missingDir, prunable: true }) + }) + + it('shields locked registrations, mirroring git prunable semantics', async () => { + const liveDir = await createTempDir() + const missingDir = path.join(liveDir, 'deleted-locked-worktree') + + const annotated = await annotatePrunableWorktreesByExistence([ + { path: liveDir, isMainWorktree: true }, + { path: missingDir, isMainWorktree: false, locked: true, lockReason: 'agent session' } + ]) + + expect(annotated[1]?.prunable).toBeUndefined() + }) +}) diff --git a/src/relay/git-handler-worktree-list.ts b/src/relay/git-handler-worktree-list.ts index 72bec899a..ac565d248 100644 --- a/src/relay/git-handler-worktree-list.ts +++ b/src/relay/git-handler-worktree-list.ts @@ -1,3 +1,4 @@ +import { stat } from 'node:fs/promises' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' @@ -30,6 +31,55 @@ export async function readRelayWorktreeList( ) } +const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 + +/** Why: Git <2.31 does not emit the `prunable` porcelain annotation, so probe + * each linked worktree path directly instead of treating a stale registration + * as a live workspace (issue #8389). Runs on the `-z`-unsupported fallback + * (Git <2.36); on Git 2.31–2.35 the annotation is already parsed, so this is a + * harmless backstop. The relay owns the filesystem, so a plain stat is + * authoritative. */ +export async function annotatePrunableWorktreesByExistence( + worktrees: Record[] +): Promise[]> { + const annotated = [...worktrees] + let nextIndex = 0 + + async function probeNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + const worktreePath = typeof worktree?.path === 'string' ? worktree.path : '' + // Git only marks linked worktrees prunable, and never locked ones (a + // lock shields the registration even when the directory is missing). The + // `locked` annotation is only parsed on Git >=2.31, so on older Git a + // locked+missing worktree cannot be shielded here. A missing main + // worktree is surfaced by the repo-level failure paths. + if ( + !worktreePath || + worktree.isMainWorktree === true || + worktree.isBare === true || + worktree.locked === true || + worktree.prunable === true + ) { + continue + } + try { + await stat(worktreePath) + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + annotated[index] = { ...worktree, prunable: true } + } + } + } + } + + const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => probeNext())) + return annotated +} + function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { return worktrees .map((worktree) => ({ diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d0424af8d..6872c5f5b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -38,6 +38,7 @@ import { removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' +import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' import { gitExecMutatesRepository } from '../shared/git-exec-mutation' @@ -1374,7 +1375,16 @@ export class GitHandler { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { signal: context?.signal }) - return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + const normalized = await this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout) + ) + // Why: this `-z`-unsupported fallback (Git <2.36) also serves Git + // <2.31, which emits no `prunable` annotation; probe each linked + // worktree path instead of treating stale registrations as live. + // On Git 2.31–2.35 the annotation is already parsed, so the probe + // is a harmless backstop (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) } catch { return [] } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 47e1a8d76..eca98f167 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -112,6 +112,14 @@ describeBinaryCompatibility('real Git binary compatibility', () => { stdout: expect.stringContaining('worktree ') }) + // Why: the `prunable` porcelain annotation landed in Git 2.31 — five + // releases before `-z` (2.36) — so only Git <2.31 emits neither and needs + // Orca's path-existence fallback (issue #8389). + await runGit(['worktree', 'add', '-b', 'compat-stale', 'stale-wt']) + await rm(join(repoPath, 'stale-wt'), { recursive: true, force: true }) + const staleList = await runGit(['worktree', 'list', '--porcelain']) + expect(staleList.stdout.includes('prunable')).toBe(supports(2, 31)) + const preferred = await runGit([ 'rev-parse', '--path-format=absolute', diff --git a/src/shared/types.ts b/src/shared/types.ts index d08684fd4..40b7ada61 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -428,6 +428,11 @@ export type GitWorktreeInfo = { isSparse?: boolean locked?: boolean lockReason?: string + /** True when Git reports the worktree as prunable (its directory is gone but + * the registration remains). Detected via the `prunable` porcelain field + * (Git ≥ 2.36) or a path-existence probe on older Git. */ + prunable?: boolean + prunableReason?: string /** True for the repo's main working tree (the first entry from `git worktree list`). * Linked worktrees created via `git worktree add` have this set to false. */ isMainWorktree: boolean