diff --git a/tools/loop-worktree/dist/worktree.js b/tools/loop-worktree/dist/worktree.js index 8c82e82f..030a1ef8 100644 --- a/tools/loop-worktree/dist/worktree.js +++ b/tools/loop-worktree/dist/worktree.js @@ -215,7 +215,22 @@ async function gitWorktreePaths(root) { if (!line.startsWith('worktree ')) continue; const abs = line.slice('worktree '.length).trim(); - const absReal = await realpath(abs); + let absReal; + try { + absReal = await realpath(abs); + } + catch (err) { + // git keeps listing a worktree (as "prunable") even after its directory + // was removed out-of-band -- a manual `rm -rf`, a crash mid-cleanup, a + // container wipe -- rather than via `git worktree remove`. realpath() + // on that now-missing path throws ENOENT, which used to propagate out + // of gc() entirely and abort the exact reconciliation it exists to do. + // Treat a missing directory as simply absent from disk so it still + // surfaces as a `dropped` manifest entry instead of crashing gc(). + if (err.code === 'ENOENT') + continue; + throw err; + } const rel = path.relative(rootReal, absReal).split(path.sep).join('/'); paths.push(rel); } diff --git a/tools/loop-worktree/src/worktree.ts b/tools/loop-worktree/src/worktree.ts index 9c802dd9..0e68a1ee 100644 --- a/tools/loop-worktree/src/worktree.ts +++ b/tools/loop-worktree/src/worktree.ts @@ -279,7 +279,20 @@ async function gitWorktreePaths(root: string): Promise { for (const line of out.split('\n')) { if (!line.startsWith('worktree ')) continue; const abs = line.slice('worktree '.length).trim(); - const absReal = await realpath(abs); + let absReal: string; + try { + absReal = await realpath(abs); + } catch (err) { + // git keeps listing a worktree (as "prunable") even after its directory + // was removed out-of-band -- a manual `rm -rf`, a crash mid-cleanup, a + // container wipe -- rather than via `git worktree remove`. realpath() + // on that now-missing path throws ENOENT, which used to propagate out + // of gc() entirely and abort the exact reconciliation it exists to do. + // Treat a missing directory as simply absent from disk so it still + // surfaces as a `dropped` manifest entry instead of crashing gc(). + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw err; + } const rel = path.relative(rootReal, absReal).split(path.sep).join('/'); paths.push(rel); } diff --git a/tools/loop-worktree/test/worktree.test.mjs b/tools/loop-worktree/test/worktree.test.mjs index 84e683e0..807484d4 100644 --- a/tools/loop-worktree/test/worktree.test.mjs +++ b/tools/loop-worktree/test/worktree.test.mjs @@ -167,6 +167,24 @@ test('gc drops manifest entries whose worktree was removed out of band', async ( } }); +test('gc does not crash when a worktree directory was deleted without git worktree remove', async () => { + const dir = await initRepo(); + try { + await createWorktree({ root: dir, runId: 'wiped', pattern: 'ci-sweeper' }); + // Simulate a manual `rm -rf` / crash mid-cleanup / container wipe: the + // directory is gone but git still lists it (as "prunable") since it was + // never told via `git worktree remove`. + await rm(path.join(dir, '.loop-worktrees', 'wiped'), { recursive: true, force: true }); + + const result = await gc({ root: dir }); + assert.deepEqual(result.dropped.map((e) => e.id), ['wiped']); + const manifest = await readManifest(dir); + assert.equal(manifest.worktrees.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('cleanup honors --older-than', async () => { const dir = await initRepo(); try {