Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/git/src/queries.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
);
});
});
14 changes: 8 additions & 6 deletions packages/git/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
34 changes: 2 additions & 32 deletions packages/git/src/sagas/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(() => {});
},
});
Expand Down Expand Up @@ -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(
Expand All @@ -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(() => {});
},
});
Expand Down
32 changes: 31 additions & 1 deletion packages/git/src/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
});
Expand Down Expand Up @@ -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);
});
});

Expand Down
24 changes: 7 additions & 17 deletions packages/git/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
this.log.info("Finalizing worktree", { worktreePath });
await this.symlinkClaudeConfig(worktreePath);
await this.symlinkClaudeLocalInstructions(worktreePath);
const linkWarnings = await processWorktreeLink(
this.mainRepoPath,
worktreePath,
Expand Down Expand Up @@ -815,19 +815,9 @@ export class WorktreeManager {
}
}

private async symlinkClaudeConfig(worktreePath: string): Promise<void> {
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<void> {
const sourceClaudeLocalMd = path.join(this.mainRepoPath, "CLAUDE.local.md");
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");

Expand Down
Loading