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 <kaynan.camargo@terceiro-sky.com.br>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Kaynan Sampaio de Camargo 2026-07-15 15:24:15 -07:00 committed by GitHub
parent 64be819790
commit 6e2a4a824d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 436 additions and 8 deletions

View File

@ -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.312.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 |

View File

@ -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', () => {

View File

@ -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()

View File

@ -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
}
])

View File

@ -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.312.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<GitWorktreeInfo[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function probeNext(): Promise<void> {
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 = {}

View File

@ -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}`,

View File

@ -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)

View File

@ -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')

View File

@ -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 },

View File

@ -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', () => {

View File

@ -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
})
}

View File

@ -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<string> {
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()
})
})

View File

@ -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.312.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<string, unknown>[]
): Promise<Record<string, unknown>[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function probeNext(): Promise<void> {
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<string, unknown>[]): RelayWorktreeInfo[] {
return worktrees
.map((worktree) => ({

View File

@ -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.312.35 the annotation is already parsed, so the probe
// is a harmless backstop (issue #8389).
return annotatePrunableWorktreesByExistence(normalized)
} catch {
return []
}

View File

@ -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',

View File

@ -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