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:
parent
64be819790
commit
6e2a4a824d
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 = {}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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) => ({
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue