Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion tools/loop-worktree/dist/worktree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 14 additions & 1 deletion tools/loop-worktree/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,20 @@ async function gitWorktreePaths(root: string): Promise<string[]> {
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);
}
Expand Down
18 changes: 18 additions & 0 deletions tools/loop-worktree/test/worktree.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down