Skip to content

Commit b7b4d4b

Browse files
centdixclaude
andcommitted
fix: survive stale git worktree registrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b5845a3 commit b7b4d4b

8 files changed

Lines changed: 184 additions & 14 deletions

File tree

backend/src/__tests__/git-adapter.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import {
66
BunGitGateway,
7+
filterLiveWorktreeEntries,
8+
listGitWorktrees,
79
listLocalGitBranches,
810
parseGitWorktreePorcelain,
911
readGitWorktreeStatus,
1012
removeGitWorktree,
1113
resolveWorktreeGitDir,
1214
resolveWorktreeRoot,
15+
worktreeEntryPathExists,
1316
} from "../adapters/git";
1417

1518
function normalizePath(path: string): string {
@@ -323,6 +326,79 @@ describe("BunGitGateway", () => {
323326
});
324327
});
325328

329+
describe("stale worktree resilience", () => {
330+
let repoRoot = "";
331+
332+
afterEach(async () => {
333+
if (repoRoot) {
334+
await rm(repoRoot, { recursive: true, force: true });
335+
repoRoot = "";
336+
}
337+
});
338+
339+
it("does not throw posix_spawn ENOENT for tryRunGit-backed callers when cwd is missing", () => {
340+
// Regression for the crash: Bun.spawnSync throws synchronously when cwd is gone.
341+
// tryRunGit-backed callers (readDiff, listUnpushedCommits, fetchBranch, ...) must
342+
// surface this as a normal failure, not propagate the throw.
343+
const gateway = new BunGitGateway();
344+
expect(() => gateway.readDiff("/tmp/webmux-missing-xyz-12345-does-not-exist")).not.toThrow();
345+
expect(gateway.readDiff("/tmp/webmux-missing-xyz-12345-does-not-exist")).toBe("");
346+
expect(() => gateway.listUnpushedCommits("/tmp/webmux-missing-xyz-12345-does-not-exist")).not.toThrow();
347+
expect(gateway.listUnpushedCommits("/tmp/webmux-missing-xyz-12345-does-not-exist")).toEqual([]);
348+
});
349+
350+
it("throws a controlled error when runGit is invoked against a missing cwd", () => {
351+
// runGit-backed callers re-raise with a readable message that names the cwd —
352+
// not a bare posix_spawn stack trace.
353+
expect(() => listLocalGitBranches("/tmp/webmux-missing-xyz-12345-does-not-exist"))
354+
.toThrow(/cwd=\/tmp\/webmux-missing-xyz-12345-does-not-exist/);
355+
});
356+
357+
it("worktreeEntryPathExists rejects missing paths", () => {
358+
expect(worktreeEntryPathExists({
359+
path: "/tmp/webmux-missing-xyz-12345-does-not-exist",
360+
head: null,
361+
branch: null,
362+
detached: false,
363+
bare: false,
364+
})).toBe(false);
365+
});
366+
367+
it("filterLiveWorktreeEntries drops stale entries and keeps live ones", async () => {
368+
repoRoot = await mkdtemp(join(tmpdir(), "webmux-filter-live-"));
369+
expect(filterLiveWorktreeEntries([
370+
{ path: repoRoot, head: "abc", branch: "main", detached: false, bare: false },
371+
{ path: "/tmp/webmux-missing-xyz-12345-does-not-exist", head: "def", branch: "stale", detached: false, bare: false },
372+
])).toEqual([
373+
{ path: repoRoot, head: "abc", branch: "main", detached: false, bare: false },
374+
]);
375+
});
376+
377+
it("BunGitGateway.listLiveWorktrees omits registrations whose directory was deleted", async () => {
378+
repoRoot = await mkdtemp(join(tmpdir(), "webmux-stale-wt-"));
379+
run(["git", "init", "-b", "main"], repoRoot);
380+
run(["git", "config", "user.name", "Test User"], repoRoot);
381+
run(["git", "config", "user.email", "test@example.com"], repoRoot);
382+
await Bun.write(join(repoRoot, "README.md"), "# repo\n");
383+
run(["git", "add", "README.md"], repoRoot);
384+
run(["git", "commit", "-m", "init"], repoRoot);
385+
386+
const worktreePath = join(repoRoot, "__worktrees", "stale");
387+
await mkdir(join(repoRoot, "__worktrees"), { recursive: true });
388+
run(["git", "worktree", "add", "-b", "stale", worktreePath], repoRoot);
389+
390+
await rm(worktreePath, { recursive: true, force: true });
391+
392+
// Raw list keeps the dangling registration (semantics preserved for removeGitWorktree).
393+
expect(listGitWorktrees(repoRoot).some((entry) => entry.path === worktreePath)).toBe(true);
394+
395+
// Live list filters it out.
396+
const gateway = new BunGitGateway();
397+
expect(gateway.listLiveWorktrees(repoRoot).some((entry) => entry.path === worktreePath)).toBe(false);
398+
expect(gateway.listLiveWorktrees(repoRoot).some((entry) => entry.path === repoRoot)).toBe(true);
399+
});
400+
});
401+
326402
describe("removeGitWorktree", () => {
327403
it("cleans up the leftover directory when git already unregistered the worktree", () => {
328404
const removedPaths: string[] = [];

backend/src/__tests__/reconciliation-service.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class FakeGitGateway implements GitGateway {
2121
private readonly worktrees: GitWorktreeEntry[],
2222
private readonly gitDirs: Map<string, string>,
2323
private readonly statuses: Map<string, GitWorktreeStatus>,
24+
private readonly liveWorktrees?: GitWorktreeEntry[],
2425
) {}
2526

2627
resolveRepoRoot(dir: string): string | null {
@@ -41,6 +42,10 @@ class FakeGitGateway implements GitGateway {
4142
return this.worktrees;
4243
}
4344

45+
listLiveWorktrees(): GitWorktreeEntry[] {
46+
return this.liveWorktrees ?? this.worktrees;
47+
}
48+
4449
listLocalBranches(): string[] {
4550
return [];
4651
}
@@ -304,6 +309,58 @@ describe("ReconciliationService", () => {
304309
expect(runtime.getWorktree("wt_stale")).toBeNull();
305310
});
306311

312+
it("ignores stale worktree registrations whose directory no longer exists", async () => {
313+
// Reproduces the ENOENT add-flow crash: a git registration points at a directory
314+
// that's gone. The service must complete the reconcile, never call git against the
315+
// stale path, and not surface it in runtime state.
316+
const repoRoot = "/repo/project";
317+
const stalePath = "/repo/project/__worktrees/feature-stale-on-disk";
318+
const livePath = "/repo/project/__worktrees/feature-live";
319+
const liveGitDir = await mkdtemp(join(tmpdir(), "webmux-reconcile-live-"));
320+
tempDirs.push(liveGitDir);
321+
322+
await writeWorktreeMeta(liveGitDir, {
323+
schemaVersion: 1,
324+
worktreeId: "wt_live",
325+
branch: "feature/live",
326+
createdAt: "2026-05-13T00:00:00.000Z",
327+
profile: "default",
328+
agent: "claude",
329+
runtime: "host",
330+
startupEnvValues: {},
331+
allocatedPorts: { FRONTEND_PORT: 3010 },
332+
});
333+
334+
const runtime = new ProjectRuntime();
335+
const mainEntry = { path: repoRoot, branch: "main", head: "aaa111", detached: false, bare: false };
336+
const liveEntry = { path: livePath, branch: "feature/live", head: "bbb222", detached: false, bare: false };
337+
const staleEntry = { path: stalePath, branch: "feature/stale-on-disk", head: "ccc333", detached: false, bare: false };
338+
339+
const git = new FakeGitGateway(
340+
[mainEntry, liveEntry, staleEntry],
341+
new Map([
342+
[livePath, liveGitDir],
343+
// No mapping for stalePath — if the service ever calls resolveWorktreeGitDir
344+
// on it, the fake throws and the test fails.
345+
]),
346+
new Map([[livePath, { dirty: false, aheadCount: 0, currentCommit: "bbb222" }]]),
347+
[mainEntry, liveEntry], // listLiveWorktrees excludes the stale entry
348+
);
349+
350+
const service = new ReconciliationService({
351+
config: TEST_CONFIG,
352+
git,
353+
tmux: new FakeTmuxGateway([]),
354+
portProbe: new FakePortProbe(new Set([3010])),
355+
runtime,
356+
});
357+
358+
await service.reconcile(repoRoot);
359+
360+
expect(runtime.getWorktree("wt_live")).not.toBeNull();
361+
expect(runtime.getWorktreeByBranch("feature/stale-on-disk")).toBeNull();
362+
});
363+
307364
it("creates synthetic ids for unmanaged worktrees", async () => {
308365
const repoRoot = "/repo/project";
309366
const unmanagedPath = "/repo/project/__worktrees/unmanaged";

backend/src/__tests__/worktree-storage.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ class FakeGitGateway implements GitGateway {
5353
return [];
5454
}
5555

56+
listLiveWorktrees() {
57+
return [];
58+
}
59+
5660
listLocalBranches(): string[] {
5761
return [];
5862
}

backend/src/adapters/git.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface GitGateway {
6767
resolveWorktreeRoot(cwd: string): string;
6868
resolveWorktreeGitDir(cwd: string): string;
6969
listWorktrees(cwd: string): GitWorktreeEntry[];
70+
listLiveWorktrees(cwd: string): GitWorktreeEntry[];
7071
listLocalBranches(cwd: string): string[];
7172
listRemoteBranches(cwd: string): string[];
7273
readWorktreeStatus(cwd: string): GitWorktreeStatus;
@@ -83,12 +84,28 @@ export interface GitGateway {
8384
hardReset(repoRoot: string, ref: string): TryGitCommandResult;
8485
}
8586

87+
function spawnGit(args: string[], cwd: string): { ok: true; result: Bun.SyncSubprocess<"pipe", "pipe"> } | { ok: false; stderr: string } {
88+
try {
89+
return {
90+
ok: true,
91+
result: Bun.spawnSync(["git", ...args], {
92+
cwd,
93+
stdout: "pipe",
94+
stderr: "pipe",
95+
}),
96+
};
97+
} catch (error) {
98+
// Bun.spawnSync throws synchronously when cwd doesn't exist (posix_spawn ENOENT).
99+
return { ok: false, stderr: `spawn failed (cwd=${cwd}): ${errorMessage(error)}` };
100+
}
101+
}
102+
86103
function runGit(args: string[], cwd: string): string {
87-
const result = Bun.spawnSync(["git", ...args], {
88-
cwd,
89-
stdout: "pipe",
90-
stderr: "pipe",
91-
});
104+
const spawned = spawnGit(args, cwd);
105+
if (!spawned.ok) {
106+
throw new Error(`git ${args.join(" ")} failed: ${spawned.stderr}`);
107+
}
108+
const { result } = spawned;
92109

93110
if (result.exitCode !== 0) {
94111
const stderr = new TextDecoder().decode(result.stderr).trim();
@@ -99,11 +116,11 @@ function runGit(args: string[], cwd: string): string {
99116
}
100117

101118
function tryRunGit(args: string[], cwd: string): TryGitCommandResult {
102-
const result = Bun.spawnSync(["git", ...args], {
103-
cwd,
104-
stdout: "pipe",
105-
stderr: "pipe",
106-
});
119+
const spawned = spawnGit(args, cwd);
120+
if (!spawned.ok) {
121+
return { ok: false, stderr: spawned.stderr };
122+
}
123+
const { result } = spawned;
107124

108125
if (result.exitCode !== 0) {
109126
return {
@@ -248,6 +265,18 @@ export function listGitWorktrees(cwd: string): GitWorktreeEntry[] {
248265
return parseGitWorktreePorcelain(output);
249266
}
250267

268+
export function worktreeEntryPathExists(entry: GitWorktreeEntry): boolean {
269+
try {
270+
return statSync(entry.path).isDirectory();
271+
} catch {
272+
return false;
273+
}
274+
}
275+
276+
export function filterLiveWorktreeEntries(entries: GitWorktreeEntry[]): GitWorktreeEntry[] {
277+
return entries.filter(worktreeEntryPathExists);
278+
}
279+
251280
export function listLocalGitBranches(cwd: string): string[] {
252281
const output = runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], cwd);
253282
return output
@@ -332,6 +361,10 @@ export class BunGitGateway implements GitGateway {
332361
return listGitWorktrees(cwd);
333362
}
334363

364+
listLiveWorktrees(cwd: string): GitWorktreeEntry[] {
365+
return filterLiveWorktreeEntries(listGitWorktrees(cwd));
366+
}
367+
335368
listLocalBranches(cwd: string): string[] {
336369
return listLocalGitBranches(cwd);
337370
}

backend/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ async function hasValidControlToken(req: Request): Promise<boolean> {
409409
async function getWorktreeGitDirs(): Promise<Map<string, string>> {
410410
const gitDirs = new Map<string, string>();
411411
const projectRoot = resolve(PROJECT_DIR);
412-
for (const entry of git.listWorktrees(projectRoot)) {
412+
for (const entry of git.listLiveWorktrees(projectRoot)) {
413413
if (entry.bare || resolve(entry.path) === projectRoot || !entry.branch) continue;
414414
gitDirs.set(entry.branch, git.resolveWorktreeGitDir(entry.path));
415415
}

backend/src/services/auto-remove-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface AutoRemoveDependencies {
1717
/** Check all worktrees for merged PRs and remove clean ones.
1818
* Called after PR sync completes -- reads the PR files that sync just wrote. */
1919
export async function runAutoRemove(deps: AutoRemoveDependencies): Promise<void> {
20-
const worktrees = deps.git.listWorktrees(deps.projectRoot)
20+
const worktrees = deps.git.listLiveWorktrees(deps.projectRoot)
2121
.filter((e) => !e.bare && e.branch !== null && e.path !== deps.projectRoot);
2222

2323
for (const entry of worktrees) {

backend/src/services/lifecycle-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ export class LifecycleService {
526526

527527
private listProjectWorktrees(): GitWorktreeEntry[] {
528528
const projectRoot = resolve(this.deps.projectRoot);
529-
return this.deps.git.listWorktrees(projectRoot).filter((entry) =>
529+
return this.deps.git.listLiveWorktrees(projectRoot).filter((entry) =>
530530
!entry.bare && resolve(entry.path) !== projectRoot
531531
);
532532
}

backend/src/services/reconciliation-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export class ReconciliationService {
150150
}
151151

152152
private async runReconcile(normalizedRepoRoot: string): Promise<void> {
153-
const worktrees = this.deps.git.listWorktrees(normalizedRepoRoot);
153+
const worktrees = this.deps.git.listLiveWorktrees(normalizedRepoRoot);
154154
const sessionName = buildProjectSessionName(normalizedRepoRoot);
155155

156156
let windows: TmuxWindowSummary[] = [];

0 commit comments

Comments
 (0)