Skip to content

Commit c2862b0

Browse files
that-github-userunknownclaude
authored
Fix worktree pruning: lock worktrees + disable agent auto-gc (#144)
Root cause: concurrent agents' git commits trigger gc --auto, which calls git worktree prune, deleting metadata for other agents' worktrees. Fix (belt and suspenders): 1. Lock worktrees immediately after creation — locked worktrees survive gc prune. Unlock before removal. 2. Disable auto-gc in agent environment via GIT_CONFIG env vars — prevents agent git commands from triggering gc entirely. 3. Verify worktree metadata directory (not just .git file) in getDiff. Removes old .git file backup approach (addressed symptom not cause). Closes #136 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c7e303c commit c2862b0

File tree

2 files changed

+32
-24
lines changed

2 files changed

+32
-24
lines changed

src/runners/claude-code.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { spawn } from "node:child_process";
2-
import { readFile, writeFile } from "node:fs/promises";
3-
import { join } from "node:path";
42
import type { AgentResult } from "../types.js";
53
import { getDiff, getDiffStats } from "../utils/git.js";
64
import type { Runner, RunnerOptions } from "./base.js";
@@ -22,15 +20,6 @@ export const claudeCodeRunner: Runner = {
2220
async run(id: number, opts: RunnerOptions): Promise<AgentResult> {
2321
const start = Date.now();
2422

25-
// Backup the .git pointer file — agents sometimes delete it during long runs
26-
const gitFilePath = join(opts.worktreePath, ".git");
27-
let gitFileBackup: string | null = null;
28-
try {
29-
gitFileBackup = await readFile(gitFilePath, "utf-8");
30-
} catch {
31-
// Not a worktree or .git is a directory — skip backup
32-
}
33-
3423
return new Promise((resolve) => {
3524
let output = "";
3625
let error = "";
@@ -57,7 +46,15 @@ export const claudeCodeRunner: Runner = {
5746
const child = spawn("claude", args, {
5847
cwd: opts.worktreePath,
5948
stdio: ["ignore", "pipe", "pipe"],
60-
env: { ...process.env },
49+
env: {
50+
...process.env,
51+
// Disable git auto-gc to prevent worktree pruning during parallel agent runs.
52+
// When gc --auto triggers, it calls "git worktree prune" which can delete
53+
// metadata for other concurrent agents' worktrees.
54+
GIT_CONFIG_COUNT: "1",
55+
GIT_CONFIG_KEY_0: "gc.auto",
56+
GIT_CONFIG_VALUE_0: "0",
57+
},
6158
});
6259

6360
child.stdout.on("data", (data: Buffer) => {
@@ -97,15 +94,6 @@ export const claudeCodeRunner: Runner = {
9794
if (settled) return;
9895
settled = true;
9996

100-
// Restore .git file if the agent deleted it during execution
101-
if (gitFileBackup) {
102-
try {
103-
await readFile(gitFilePath, "utf-8");
104-
} catch {
105-
await writeFile(gitFilePath, gitFileBackup).catch(() => {});
106-
}
107-
}
108-
10997
const duration = Date.now() - start;
11098
const diff = await getDiff(opts.worktreePath);
11199
const stats = await getDiffStats(opts.worktreePath);

src/utils/git.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execFile } from "node:child_process";
22
import { randomUUID } from "node:crypto";
3-
import { access, mkdtemp, rm } from "node:fs/promises";
3+
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { dirname, join, resolve } from "node:path";
66
import { promisify } from "node:util";
@@ -31,6 +31,13 @@ export async function createWorktree(id: number): Promise<string> {
3131
cwd: repoRoot,
3232
});
3333

34+
// Lock the worktree to prevent git gc --auto from pruning it while agents run.
35+
// Without this, concurrent agents' git commits can trigger gc which prunes
36+
// other worktrees' metadata from .git/worktrees/.
37+
await exec("git", ["worktree", "lock", "--reason", "thinktank agent in use", dir], {
38+
cwd: repoRoot,
39+
});
40+
3441
// Symlink node_modules from the main repo so tests and tools work in worktrees.
3542
// Git worktrees don't include gitignored directories like node_modules.
3643
const mainNodeModules = join(repoRoot, "node_modules");
@@ -49,6 +56,9 @@ export async function createWorktree(id: number): Promise<string> {
4956
export async function removeWorktree(worktreePath: string): Promise<void> {
5057
const repoRoot = await getMainRepoRoot();
5158

59+
// Unlock the worktree before removal (it was locked during creation)
60+
await exec("git", ["worktree", "unlock", worktreePath], { cwd: repoRoot }).catch(() => {});
61+
5262
// Remove node_modules symlink/junction BEFORE removing worktree.
5363
// On Windows, rm -rf follows junctions and deletes the target.
5464
try {
@@ -77,9 +87,14 @@ export async function removeWorktree(worktreePath: string): Promise<void> {
7787
export async function getDiff(worktreePath: string): Promise<string> {
7888
const absPath = resolve(worktreePath);
7989
try {
80-
// Verify worktree is still a git repo before running git commands
90+
// Verify worktree .git file AND its metadata directory still exist.
91+
// git gc --auto can prune .git/worktrees/NAME/ even if the .git pointer file remains.
8192
await access(join(absPath, ".git"));
82-
await exec("git", ["rev-parse", "--git-dir"], { cwd: absPath });
93+
const gitContent = await readFile(join(absPath, ".git"), "utf-8");
94+
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
95+
if (gitdirMatch?.[1]) {
96+
await access(gitdirMatch[1].trim());
97+
}
8398

8499
await exec("git", ["add", "-A"], { cwd: absPath });
85100
await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {});
@@ -99,6 +114,11 @@ export async function getDiffStats(
99114
const absPath = resolve(worktreePath);
100115
try {
101116
await access(join(absPath, ".git"));
117+
const gitContent = await readFile(join(absPath, ".git"), "utf-8");
118+
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
119+
if (gitdirMatch?.[1]) {
120+
await access(gitdirMatch[1].trim());
121+
}
102122
await exec("git", ["add", "-A"], { cwd: absPath });
103123
await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {});
104124
const { stdout } = await exec("git", ["diff", "--cached", "--stat", "HEAD"], {

0 commit comments

Comments
 (0)