Skip to content

Commit d0002a5

Browse files
fix(git): stop implicitly linking .claude (#3562)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: richardsolomou <2622273+richardsolomou@users.noreply.github.com>
1 parent c4138c6 commit d0002a5

5 files changed

Lines changed: 83 additions & 57 deletions

File tree

packages/git/src/queries.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
1+
import {
2+
mkdir,
3+
mkdtemp,
4+
readFile,
5+
rm,
6+
unlink,
7+
writeFile,
8+
} from "node:fs/promises";
29
import { devNull, tmpdir } from "node:os";
310
import path from "node:path";
411
import { afterEach, beforeEach, describe, expect, it } from "vitest";
512
import { createGitClient } from "./client";
613
import {
14+
addToLocalExclude,
715
anyBranchRefExists,
816
type ChangedFileInfo,
917
computeDiffStatsFromFiles,
@@ -648,3 +656,29 @@ describe("listAllFiles", () => {
648656
expect(files).toContain("file.txt");
649657
});
650658
});
659+
660+
describe("addToLocalExclude", () => {
661+
let repoDir: string;
662+
663+
afterEach(async () => {
664+
if (repoDir) {
665+
await rm(repoDir, { recursive: true, force: true });
666+
}
667+
});
668+
669+
it("does not confuse a nested pattern with an exact pattern", async () => {
670+
repoDir = await setupRepo();
671+
const git = createGitClient(repoDir);
672+
const excludePath = path.resolve(
673+
repoDir,
674+
await git.revparse(["--git-path", "info/exclude"]),
675+
);
676+
await writeFile(excludePath, "**/.claude/worktrees/\n");
677+
678+
await addToLocalExclude(repoDir, ".claude");
679+
680+
expect(await readFile(excludePath, "utf-8")).toBe(
681+
"**/.claude/worktrees/\n.claude\n",
682+
);
683+
});
684+
});

packages/git/src/queries.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,12 +1379,14 @@ export async function addToLocalExclude(
13791379
content = await fs.readFile(excludePath, "utf-8");
13801380
} catch {}
13811381

1382-
const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
1383-
const patternWithoutSlash = pattern.replace(/^\//, "");
1384-
if (
1385-
content.includes(normalizedPattern) ||
1386-
content.includes(patternWithoutSlash)
1387-
) {
1382+
const normalizePattern = (value: string): string =>
1383+
value.startsWith("/") ? value.slice(1) : value;
1384+
const normalizedPattern = normalizePattern(pattern);
1385+
const existingPatterns = content
1386+
.split(/\r?\n/)
1387+
.filter((line) => line && !line.startsWith("#"))
1388+
.map(normalizePattern);
1389+
if (existingPatterns.includes(normalizedPattern)) {
13881390
return;
13891391
}
13901392

packages/git/src/sagas/worktree.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,8 @@ export class CreateWorktreeSaga extends GitSaga<
8787
});
8888

8989
await this.step({
90-
name: "symlink-claude-config",
90+
name: "symlink-claude-local-instructions",
9191
execute: async () => {
92-
const sourceClaudeDir = path.join(baseDir, ".claude");
93-
const targetClaudeDir = path.join(worktreePath, ".claude");
94-
const linkedDir = await safeSymlink(
95-
sourceClaudeDir,
96-
targetClaudeDir,
97-
"dir",
98-
);
99-
if (linkedDir) {
100-
await addToLocalExclude(worktreePath, ".claude", {
101-
abortSignal: signal,
102-
});
103-
}
104-
10592
const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md");
10693
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
10794
const linkedFile = await safeSymlink(
@@ -116,9 +103,7 @@ export class CreateWorktreeSaga extends GitSaga<
116103
}
117104
},
118105
rollback: async () => {
119-
const targetClaudeDir = path.join(worktreePath, ".claude");
120106
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
121-
await fs.rm(targetClaudeDir, { force: true }).catch(() => {});
122107
await fs.rm(targetClaudeLocalMd, { force: true }).catch(() => {});
123108
},
124109
});
@@ -191,21 +176,8 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
191176
});
192177

193178
await this.step({
194-
name: "symlink-claude-config",
179+
name: "symlink-claude-local-instructions",
195180
execute: async () => {
196-
const sourceClaudeDir = path.join(baseDir, ".claude");
197-
const targetClaudeDir = path.join(worktreePath, ".claude");
198-
const linkedDir = await safeSymlink(
199-
sourceClaudeDir,
200-
targetClaudeDir,
201-
"dir",
202-
);
203-
if (linkedDir) {
204-
await addToLocalExclude(worktreePath, ".claude", {
205-
abortSignal: signal,
206-
});
207-
}
208-
209181
const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md");
210182
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
211183
const linkedFile = await safeSymlink(
@@ -220,9 +192,7 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
220192
}
221193
},
222194
rollback: async () => {
223-
const targetClaudeDir = path.join(worktreePath, ".claude");
224195
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
225-
await fs.rm(targetClaudeDir, { force: true }).catch(() => {});
226196
await fs.rm(targetClaudeLocalMd, { force: true }).catch(() => {});
227197
},
228198
});

packages/git/src/worktree.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe("WorktreeManager worktree link/include processing", () => {
247247
await seedGit.addConfig("commit.gpgsign", "false");
248248
await writeFile(
249249
path.join(seedDir, ".gitignore"),
250-
".env\n.envrc\nnode_modules/\n",
250+
".claude/\n.env\n.envrc\nCLAUDE.local.md\nnode_modules/\n",
251251
);
252252
await writeFile(path.join(seedDir, ".worktreelink"), "# secrets\n.envrc\n");
253253
await writeFile(path.join(seedDir, ".worktreeinclude"), ".env\n");
@@ -264,6 +264,12 @@ describe("WorktreeManager worktree link/include processing", () => {
264264

265265
await writeFile(path.join(localDir, ".env"), "secret\n");
266266
await writeFile(path.join(localDir, ".envrc"), "export FOO=1\n");
267+
await mkdir(path.join(localDir, ".claude"));
268+
await writeFile(
269+
path.join(localDir, ".claude", "settings.local.json"),
270+
"{}\n",
271+
);
272+
await writeFile(path.join(localDir, "CLAUDE.local.md"), "local rules\n");
267273
await mkdir(path.join(localDir, "node_modules", "dep"), {
268274
recursive: true,
269275
});
@@ -306,6 +312,30 @@ describe("WorktreeManager worktree link/include processing", () => {
306312
expect(await dirExists(path.join(info.worktreePath, "node_modules"))).toBe(
307313
false,
308314
);
315+
expect(await dirExists(path.join(info.worktreePath, ".claude"))).toBe(
316+
false,
317+
);
318+
319+
const localInstructions = await lstat(
320+
path.join(info.worktreePath, "CLAUDE.local.md"),
321+
);
322+
expect(localInstructions.isSymbolicLink()).toBe(true);
323+
});
324+
325+
it("links .claude when explicitly configured", async () => {
326+
await writeFile(
327+
path.join(localDir, ".worktreelink"),
328+
"# secrets\n.envrc\n.claude\n",
329+
);
330+
const manager = new WorktreeManager({
331+
mainRepoPath: localDir,
332+
worktreeBasePath: worktreeBaseDir,
333+
});
334+
335+
const info = await manager.createWorktree({ baseBranch: "main" });
336+
337+
const linked = await lstat(path.join(info.worktreePath, ".claude"));
338+
expect(linked.isSymbolicLink()).toBe(true);
309339
});
310340
});
311341

packages/git/src/worktree.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -403,16 +403,16 @@ export class WorktreeManager {
403403
}
404404

405405
/**
406-
* Runs the post-create steps shared by every worktree: symlink the Claude
407-
* config, then process the worktree link/include files and the post-checkout
408-
* hook.
406+
* Runs the post-create steps shared by every worktree: link local Claude
407+
* instructions, then process the worktree link/include files and the
408+
* post-checkout hook.
409409
*/
410410
private async finalizeWorktree(
411411
worktreePath: string,
412412
onOutput?: (data: string) => void,
413413
): Promise<void> {
414414
this.log.info("Finalizing worktree", { worktreePath });
415-
await this.symlinkClaudeConfig(worktreePath);
415+
await this.symlinkClaudeLocalInstructions(worktreePath);
416416
const linkWarnings = await processWorktreeLink(
417417
this.mainRepoPath,
418418
worktreePath,
@@ -815,19 +815,9 @@ export class WorktreeManager {
815815
}
816816
}
817817

818-
private async symlinkClaudeConfig(worktreePath: string): Promise<void> {
819-
const sourceClaudeDir = path.join(this.mainRepoPath, ".claude");
820-
const targetClaudeDir = path.join(worktreePath, ".claude");
821-
822-
const linkedDir = await safeSymlink(
823-
sourceClaudeDir,
824-
targetClaudeDir,
825-
"dir",
826-
);
827-
if (linkedDir) {
828-
await addToLocalExclude(worktreePath, ".claude");
829-
}
830-
818+
private async symlinkClaudeLocalInstructions(
819+
worktreePath: string,
820+
): Promise<void> {
831821
const sourceClaudeLocalMd = path.join(this.mainRepoPath, "CLAUDE.local.md");
832822
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
833823

0 commit comments

Comments
 (0)