CB-634: write the overlay exclude to the common git dir, not the per-worktree gitdir

git reads info/exclude from the common dir for a linked worktree (only
info/sparse-checkout is per-worktree), so the entry written into
<common>/worktrees/<name>/info/exclude was never honoured and CLAUDE.local.md
showed as untracked -- at risk of being swept into a worker's PR. Derive the
common dir (<common>/worktrees/<name> -> <common>) and write there. Found by
dogfooding a real spawn on fleet01; the test now uses the real worktree layout
and asserts the entry lands in the common dir, not the per-worktree gitdir.
This commit is contained in:
Dai Ha
2026-08-23 20:06:36 +02:00
parent 5a8cf4cb4d
commit a5efb7c676
2 changed files with 26 additions and 11 deletions
@@ -356,7 +356,8 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
/**
* Deliver the shared IDE guidance ({@link PeerLauncher#ideOverlayText}) as an on-disk
* {@code CLAUDE.local.md} overlay beside the project's own {@code CLAUDE.md} (CB-634), and
* register the overlay in the worktree's {@code info/exclude} so it never shows as untracked.
* register the overlay in the repository's common {@code info/exclude} so it never shows as
* untracked (git reads a worktree's excludes from the common dir, not the per-worktree gitdir).
*
* <p><strong>Safety gate:</strong> the overlay is written ONLY when {@code cwd/.git} is a
* <em>regular file</em> — a provisioned worktree keeps a {@code .git} FILE holding a
@@ -382,7 +383,16 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
if (!gitDir.isAbsolute()) {
gitDir = Path.of(cwd).resolve(gitDir).normalize();
}
Path exclude = gitDir.resolve("info").resolve("exclude");
// git reads info/exclude from the COMMON dir, never the per-worktree gitdir (only
// info/sparse-checkout is per-worktree). A provisioned worktree's gitdir is
// <common>/worktrees/<name>, so the common dir is two levels up; writing the entry into
// the per-worktree gitdir leaves it un-honoured and the overlay shows as untracked.
Path commonDir = gitDir;
if (gitDir.getParent() != null && gitDir.getParent().getFileName() != null
&& "worktrees".equals(gitDir.getParent().getFileName().toString())) {
commonDir = gitDir.getParent().getParent();
}
Path exclude = commonDir.resolve("info").resolve("exclude");
Files.createDirectories(exclude.getParent());
String overlayLine = "CLAUDE.local.md";
if (!Files.exists(exclude) || Files.readAllLines(exclude).stream().noneMatch(overlayLine::equals)) {
@@ -138,14 +138,17 @@ class ClaudeCodeLauncherTest {
}
// CB-634: the IDE guidance is delivered as a CLAUDE.local.md overlay (written only into a
// provisioned worktree — cwd with a `.git` FILE) and registered in the worktree's info/exclude.
// provisioned worktree — cwd with a `.git` FILE) and registered in the repository's COMMON
// info/exclude. git reads a worktree's excludes from the common dir, not the per-worktree
// gitdir (only info/sparse-checkout is per-worktree), so a real provisioned layout
// <common>/worktrees/<name> must land the entry in <common>/info/exclude.
@Test
void writeIdeOverlayWritesClaudeLocalAndAddsItToInfoExclude(@TempDir Path root) throws Exception {
void writeIdeOverlayWritesClaudeLocalAndAddsItToCommonInfoExclude(@TempDir Path root) throws Exception {
Path worktree = Files.createDirectory(root.resolve("worktree"));
Path gitDir = Files.createDirectory(root.resolve("gitdir"));
Files.createDirectories(gitDir.resolve("info"));
// A provisioned worktree keeps its gitdir as a `.git` FILE holding a gitdir: <path> pointer.
// Real worktree layout: the .git FILE points at <common>/worktrees/<name>.
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
FakeHerdr herdr = new FakeHerdr();
@@ -156,10 +159,12 @@ class ClaudeCodeLauncherTest {
assertTrue(Files.exists(overlay), "the overlay is written beside the project's CLAUDE.md");
assertTrue(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
"the overlay pins every ide_* call to the member's own worktree");
Path exclude = gitDir.resolve("info").resolve("exclude");
assertTrue(Files.exists(exclude), "info/exclude is created from the gitdir pointer");
assertTrue(Files.readAllLines(exclude).contains("CLAUDE.local.md"),
"the overlay is registered so it never shows as untracked");
Path commonExclude = commonDir.resolve("info").resolve("exclude");
assertTrue(Files.exists(commonExclude), "info/exclude is created in the COMMON git dir");
assertTrue(Files.readAllLines(commonExclude).contains("CLAUDE.local.md"),
"the overlay is registered in the common exclude git actually honours for a worktree");
assertFalse(Files.exists(gitDir.resolve("info").resolve("exclude")),
"the entry must NOT go to the per-worktree gitdir, which git ignores for excludes");
}
@Test