diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 3c099b8ae6e..55d47bc680a 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -402,30 +402,39 @@ describe('registerWorktreeHandlers', () => { } }) - it('retries stale Git registration cleanup after prior local filesystem recovery', async () => { - setPlatform('win32') - const missingWorktreePath = 'C:\\workspace\\already-removed' - const worktreeId = `repo-1::${missingWorktreePath}` - const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath) - listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + it.each([false, true])( + 'retries missing registration cleanup (prunable marker: %s)', + async (prunableMarker) => { + setPlatform('win32') + const missingWorktreePath = prunableMarker + ? 'C:\\workspace\\already-removed\\.git' + : 'C:\\workspace\\already-removed' + const worktreeId = `repo-1::${missingWorktreePath}` + const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath).map((row) => + prunableMarker && row.path === missingWorktreePath + ? { ...row, branch: 'refs/heads/feature', prunable: true } + : row + ) + listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) - const result = await handlers['worktrees:remove'](null, { - worktreeId, - force: true - }) + const result = await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) - expect(result).toEqual({ - preservedBranch: { branchName: 'feature', head: 'feature' } - }) - expect(runHookMock).not.toHaveBeenCalled() - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { - cwd: '/workspace/repo' - }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - }) + expect(result).toEqual({ + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } + ) it('cleans a prunable Git-file row before archive or checkout teardown', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-prunable-ipc-')) diff --git a/src/main/worktree-prunable-git-file.test.ts b/src/main/worktree-prunable-git-file.test.ts index de5921858b6..f05e5e5cb4c 100644 --- a/src/main/worktree-prunable-git-file.test.ts +++ b/src/main/worktree-prunable-git-file.test.ts @@ -52,6 +52,10 @@ describe('prunable Git-file registration proof', () => { await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) } ) + it('leaves a vanished marker to existing missing-path recovery', async () => { + statPath.mockRejectedValue(Object.assign(new Error('marker vanished'), { code: 'ENOENT' })) + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) + }) it('does not turn host failure into cleanup permission', async () => { statPath.mockRejectedValue(new Error('host unavailable')) await expect(isPrunableGitFileWorktree(worktree)).rejects.toThrow('host unavailable') diff --git a/src/main/worktree-prunable-git-file.ts b/src/main/worktree-prunable-git-file.ts index d03c31e7295..a89f3408eb3 100644 --- a/src/main/worktree-prunable-git-file.ts +++ b/src/main/worktree-prunable-git-file.ts @@ -1,3 +1,4 @@ +import { isENOENT } from './ipc/filesystem-path-containment' import type { GitWorktreeInfo } from '../shared/worktree/types' import type { LocalWorktreeFilesystemOptions } from './local-worktree-filesystem' import { getLocalWorktreePathAccess, toLocalWorktreeRuntimePath } from './local-worktree-filesystem' @@ -20,7 +21,15 @@ export async function isPrunableGitFileWorktree( return false } const access = getLocalWorktreePathAccess(options) - const entry = await access.statPath(toLocalWorktreeRuntimePath(worktree.path, options)) + const entry = await access + .statPath(toLocalWorktreeRuntimePath(worktree.path, options)) + .catch((error: unknown) => { + // A vanished marker leaves missing-path recovery to its existing stricter gate. + if (isENOENT(error)) { + return null + } + throw error + }) if (!entry || typeof entry !== 'object') { return false }