Skip to content

Commit 8d45ad3

Browse files
that-github-userunknownclaude
authored
Fix nested worktree issue: use git-common-dir for main repo resolution (#89)
Add getMainRepoRoot() that uses git rev-parse --git-common-dir to find the main repository root even when called from inside a worktree. This fixes createWorktree, removeWorktree, and cleanupBranches which previously used getRepoRoot() that returns the worktree path, not the main repo. Fixes the root cause of test failures during dogfooding — tests can now create worktrees from inside agent worktrees because git operations target the main repo. Generated by thinktank with Opus (5 agents, Agent #5 recommended — fixed root cause instead of skipping tests). Closes #86 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cea0dbb commit 8d45ad3

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/utils/git.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { after, before, describe, it } from "node:test";
2+
import { after, describe, it } from "node:test";
33
import {
44
cleanupBranches,
55
createWorktree,

src/utils/git.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
22
import { randomUUID } from "node:crypto";
33
import { mkdtemp, rm } from "node:fs/promises";
44
import { tmpdir } from "node:os";
5-
import { join } from "node:path";
5+
import { dirname, join } from "node:path";
66
import { promisify } from "node:util";
77

88
const exec = promisify(execFile);
@@ -12,20 +12,57 @@ export async function getRepoRoot(): Promise<string> {
1212
return stdout.trim();
1313
}
1414

15+
/**
16+
* Returns the root of the main repository, even when called from inside a worktree.
17+
* This is necessary because git does not support nested worktrees.
18+
*/
19+
async function getMainRepoRoot(): Promise<string> {
20+
const { stdout } = await exec("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
21+
// --git-common-dir returns /path/to/main-repo/.git for both worktrees and the main repo
22+
return dirname(stdout.trim());
23+
}
24+
1525
export async function createWorktree(id: number): Promise<string> {
16-
const repoRoot = await getRepoRoot();
26+
const repoRoot = await getMainRepoRoot();
1727
const dir = await mkdtemp(join(tmpdir(), `thinktank-agent-${id}-`));
1828
const branchName = `thinktank/agent-${id}-${randomUUID().slice(0, 8)}`;
1929

2030
await exec("git", ["worktree", "add", "-b", branchName, dir], {
2131
cwd: repoRoot,
2232
});
2333

34+
// Symlink node_modules from the main repo so tests and tools work in worktrees.
35+
// Git worktrees don't include gitignored directories like node_modules.
36+
const mainNodeModules = join(repoRoot, "node_modules");
37+
const worktreeNodeModules = join(dir, "node_modules");
38+
try {
39+
const { lstat, symlink } = await import("node:fs/promises");
40+
await lstat(mainNodeModules);
41+
await symlink(mainNodeModules, worktreeNodeModules, "junction");
42+
} catch {
43+
// No node_modules in main repo or symlink failed — not critical
44+
}
45+
2446
return dir;
2547
}
2648

2749
export async function removeWorktree(worktreePath: string): Promise<void> {
28-
const repoRoot = await getRepoRoot();
50+
const repoRoot = await getMainRepoRoot();
51+
52+
// Remove node_modules symlink/junction BEFORE removing worktree.
53+
// On Windows, rm -rf follows junctions and deletes the target.
54+
try {
55+
const nmPath = join(worktreePath, "node_modules");
56+
const { lstat, unlink } = await import("node:fs/promises");
57+
const stat = await lstat(nmPath);
58+
if (stat.isSymbolicLink() || stat.isDirectory()) {
59+
// unlink removes the junction/symlink without following it
60+
await unlink(nmPath).catch(() => {});
61+
}
62+
} catch {
63+
// No symlink to remove
64+
}
65+
2966
try {
3067
await exec("git", ["worktree", "remove", worktreePath, "--force"], {
3168
cwd: repoRoot,
@@ -88,7 +125,7 @@ export async function getDiffStats(
88125
}
89126

90127
export async function cleanupBranches(): Promise<void> {
91-
const repoRoot = await getRepoRoot();
128+
const repoRoot = await getMainRepoRoot();
92129
const { stdout } = await exec("git", ["branch", "--list", "thinktank/*"], {
93130
cwd: repoRoot,
94131
});

0 commit comments

Comments
 (0)