Skip to content

Commit 50eb9d0

Browse files
fix(loop-worktree): don't crash gc() when a worktree dir was removed out-of-band (#518)
gitWorktreePaths() called realpath() on every path from `git worktree list --porcelain` with no error handling. git keeps listing a worktree (annotated "prunable") even after its directory is gone -- a manual rm -rf, a crash mid-cleanup, a container wipe -- rather than removed via `git worktree remove`. realpath() on that missing path throws ENOENT, which propagated out of gc() entirely instead of letting the entry surface as `dropped`, aborting the exact reconciliation gc() exists to perform. Treat a missing directory as simply absent from disk instead of crashing. Adds a regression test that deletes a worktree directory directly (bypassing git worktree remove) and asserts gc() still completes and reports it as dropped. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e4247df commit 50eb9d0

3 files changed

Lines changed: 48 additions & 2 deletions

File tree

tools/loop-worktree/dist/worktree.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,22 @@ async function gitWorktreePaths(root) {
215215
if (!line.startsWith('worktree '))
216216
continue;
217217
const abs = line.slice('worktree '.length).trim();
218-
const absReal = await realpath(abs);
218+
let absReal;
219+
try {
220+
absReal = await realpath(abs);
221+
}
222+
catch (err) {
223+
// git keeps listing a worktree (as "prunable") even after its directory
224+
// was removed out-of-band -- a manual `rm -rf`, a crash mid-cleanup, a
225+
// container wipe -- rather than via `git worktree remove`. realpath()
226+
// on that now-missing path throws ENOENT, which used to propagate out
227+
// of gc() entirely and abort the exact reconciliation it exists to do.
228+
// Treat a missing directory as simply absent from disk so it still
229+
// surfaces as a `dropped` manifest entry instead of crashing gc().
230+
if (err.code === 'ENOENT')
231+
continue;
232+
throw err;
233+
}
219234
const rel = path.relative(rootReal, absReal).split(path.sep).join('/');
220235
paths.push(rel);
221236
}

tools/loop-worktree/src/worktree.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,20 @@ async function gitWorktreePaths(root: string): Promise<string[]> {
279279
for (const line of out.split('\n')) {
280280
if (!line.startsWith('worktree ')) continue;
281281
const abs = line.slice('worktree '.length).trim();
282-
const absReal = await realpath(abs);
282+
let absReal: string;
283+
try {
284+
absReal = await realpath(abs);
285+
} catch (err) {
286+
// git keeps listing a worktree (as "prunable") even after its directory
287+
// was removed out-of-band -- a manual `rm -rf`, a crash mid-cleanup, a
288+
// container wipe -- rather than via `git worktree remove`. realpath()
289+
// on that now-missing path throws ENOENT, which used to propagate out
290+
// of gc() entirely and abort the exact reconciliation it exists to do.
291+
// Treat a missing directory as simply absent from disk so it still
292+
// surfaces as a `dropped` manifest entry instead of crashing gc().
293+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
294+
throw err;
295+
}
283296
const rel = path.relative(rootReal, absReal).split(path.sep).join('/');
284297
paths.push(rel);
285298
}

tools/loop-worktree/test/worktree.test.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ test('gc drops manifest entries whose worktree was removed out of band', async (
167167
}
168168
});
169169

170+
test('gc does not crash when a worktree directory was deleted without git worktree remove', async () => {
171+
const dir = await initRepo();
172+
try {
173+
await createWorktree({ root: dir, runId: 'wiped', pattern: 'ci-sweeper' });
174+
// Simulate a manual `rm -rf` / crash mid-cleanup / container wipe: the
175+
// directory is gone but git still lists it (as "prunable") since it was
176+
// never told via `git worktree remove`.
177+
await rm(path.join(dir, '.loop-worktrees', 'wiped'), { recursive: true, force: true });
178+
179+
const result = await gc({ root: dir });
180+
assert.deepEqual(result.dropped.map((e) => e.id), ['wiped']);
181+
const manifest = await readManifest(dir);
182+
assert.equal(manifest.worktrees.length, 0);
183+
} finally {
184+
await rm(dir, { recursive: true, force: true });
185+
}
186+
});
187+
170188
test('cleanup honors --older-than', async () => {
171189
const dir = await initRepo();
172190
try {

0 commit comments

Comments
 (0)