diff --git a/packages/git/src/queries.test.ts b/packages/git/src/queries.test.ts index 7cee73847f..d9e6403873 100644 --- a/packages/git/src/queries.test.ts +++ b/packages/git/src/queries.test.ts @@ -1,9 +1,17 @@ -import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + unlink, + writeFile, +} from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createGitClient } from "./client"; import { + addToLocalExclude, anyBranchRefExists, type ChangedFileInfo, computeDiffStatsFromFiles, @@ -648,3 +656,29 @@ describe("listAllFiles", () => { expect(files).toContain("file.txt"); }); }); + +describe("addToLocalExclude", () => { + let repoDir: string; + + afterEach(async () => { + if (repoDir) { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it("does not confuse a nested pattern with an exact pattern", async () => { + repoDir = await setupRepo(); + const git = createGitClient(repoDir); + const excludePath = path.resolve( + repoDir, + await git.revparse(["--git-path", "info/exclude"]), + ); + await writeFile(excludePath, "**/.claude/worktrees/\n"); + + await addToLocalExclude(repoDir, ".claude"); + + expect(await readFile(excludePath, "utf-8")).toBe( + "**/.claude/worktrees/\n.claude\n", + ); + }); +}); diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index 10148770c9..db28e350fc 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -1379,12 +1379,14 @@ export async function addToLocalExclude( content = await fs.readFile(excludePath, "utf-8"); } catch {} - const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`; - const patternWithoutSlash = pattern.replace(/^\//, ""); - if ( - content.includes(normalizedPattern) || - content.includes(patternWithoutSlash) - ) { + const normalizePattern = (value: string): string => + value.startsWith("/") ? value.slice(1) : value; + const normalizedPattern = normalizePattern(pattern); + const existingPatterns = content + .split(/\r?\n/) + .filter((line) => line && !line.startsWith("#")) + .map(normalizePattern); + if (existingPatterns.includes(normalizedPattern)) { return; } diff --git a/packages/git/src/sagas/worktree.ts b/packages/git/src/sagas/worktree.ts index ed268a6182..dbdbd5a48c 100644 --- a/packages/git/src/sagas/worktree.ts +++ b/packages/git/src/sagas/worktree.ts @@ -87,21 +87,8 @@ export class CreateWorktreeSaga extends GitSaga< }); await this.step({ - name: "symlink-claude-config", + name: "symlink-claude-local-instructions", execute: async () => { - const sourceClaudeDir = path.join(baseDir, ".claude"); - const targetClaudeDir = path.join(worktreePath, ".claude"); - const linkedDir = await safeSymlink( - sourceClaudeDir, - targetClaudeDir, - "dir", - ); - if (linkedDir) { - await addToLocalExclude(worktreePath, ".claude", { - abortSignal: signal, - }); - } - const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md"); const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md"); const linkedFile = await safeSymlink( @@ -116,9 +103,7 @@ export class CreateWorktreeSaga extends GitSaga< } }, rollback: async () => { - const targetClaudeDir = path.join(worktreePath, ".claude"); const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md"); - await fs.rm(targetClaudeDir, { force: true }).catch(() => {}); await fs.rm(targetClaudeLocalMd, { force: true }).catch(() => {}); }, }); @@ -191,21 +176,8 @@ export class CreateWorktreeForBranchSaga extends GitSaga< }); await this.step({ - name: "symlink-claude-config", + name: "symlink-claude-local-instructions", execute: async () => { - const sourceClaudeDir = path.join(baseDir, ".claude"); - const targetClaudeDir = path.join(worktreePath, ".claude"); - const linkedDir = await safeSymlink( - sourceClaudeDir, - targetClaudeDir, - "dir", - ); - if (linkedDir) { - await addToLocalExclude(worktreePath, ".claude", { - abortSignal: signal, - }); - } - const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md"); const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md"); const linkedFile = await safeSymlink( @@ -220,9 +192,7 @@ export class CreateWorktreeForBranchSaga extends GitSaga< } }, rollback: async () => { - const targetClaudeDir = path.join(worktreePath, ".claude"); const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md"); - await fs.rm(targetClaudeDir, { force: true }).catch(() => {}); await fs.rm(targetClaudeLocalMd, { force: true }).catch(() => {}); }, }); diff --git a/packages/git/src/worktree.test.ts b/packages/git/src/worktree.test.ts index f6ccd085d9..ea4f70697d 100644 --- a/packages/git/src/worktree.test.ts +++ b/packages/git/src/worktree.test.ts @@ -247,7 +247,7 @@ describe("WorktreeManager worktree link/include processing", () => { await seedGit.addConfig("commit.gpgsign", "false"); await writeFile( path.join(seedDir, ".gitignore"), - ".env\n.envrc\nnode_modules/\n", + ".claude/\n.env\n.envrc\nCLAUDE.local.md\nnode_modules/\n", ); await writeFile(path.join(seedDir, ".worktreelink"), "# secrets\n.envrc\n"); await writeFile(path.join(seedDir, ".worktreeinclude"), ".env\n"); @@ -264,6 +264,12 @@ describe("WorktreeManager worktree link/include processing", () => { await writeFile(path.join(localDir, ".env"), "secret\n"); await writeFile(path.join(localDir, ".envrc"), "export FOO=1\n"); + await mkdir(path.join(localDir, ".claude")); + await writeFile( + path.join(localDir, ".claude", "settings.local.json"), + "{}\n", + ); + await writeFile(path.join(localDir, "CLAUDE.local.md"), "local rules\n"); await mkdir(path.join(localDir, "node_modules", "dep"), { recursive: true, }); @@ -306,6 +312,30 @@ describe("WorktreeManager worktree link/include processing", () => { expect(await dirExists(path.join(info.worktreePath, "node_modules"))).toBe( false, ); + expect(await dirExists(path.join(info.worktreePath, ".claude"))).toBe( + false, + ); + + const localInstructions = await lstat( + path.join(info.worktreePath, "CLAUDE.local.md"), + ); + expect(localInstructions.isSymbolicLink()).toBe(true); + }); + + it("links .claude when explicitly configured", async () => { + await writeFile( + path.join(localDir, ".worktreelink"), + "# secrets\n.envrc\n.claude\n", + ); + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + + const info = await manager.createWorktree({ baseBranch: "main" }); + + const linked = await lstat(path.join(info.worktreePath, ".claude")); + expect(linked.isSymbolicLink()).toBe(true); }); }); diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 4f2e164194..31ccc259e1 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -403,16 +403,16 @@ export class WorktreeManager { } /** - * Runs the post-create steps shared by every worktree: symlink the Claude - * config, then process the worktree link/include files and the post-checkout - * hook. + * Runs the post-create steps shared by every worktree: link local Claude + * instructions, then process the worktree link/include files and the + * post-checkout hook. */ private async finalizeWorktree( worktreePath: string, onOutput?: (data: string) => void, ): Promise { this.log.info("Finalizing worktree", { worktreePath }); - await this.symlinkClaudeConfig(worktreePath); + await this.symlinkClaudeLocalInstructions(worktreePath); const linkWarnings = await processWorktreeLink( this.mainRepoPath, worktreePath, @@ -815,19 +815,9 @@ export class WorktreeManager { } } - private async symlinkClaudeConfig(worktreePath: string): Promise { - const sourceClaudeDir = path.join(this.mainRepoPath, ".claude"); - const targetClaudeDir = path.join(worktreePath, ".claude"); - - const linkedDir = await safeSymlink( - sourceClaudeDir, - targetClaudeDir, - "dir", - ); - if (linkedDir) { - await addToLocalExclude(worktreePath, ".claude"); - } - + private async symlinkClaudeLocalInstructions( + worktreePath: string, + ): Promise { const sourceClaudeLocalMd = path.join(this.mainRepoPath, "CLAUDE.local.md"); const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");