Skip to content
Open
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
84 changes: 84 additions & 0 deletions src/main/worktree/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createWorktree,
excludeClaudeDir,
generateWorktreePath,
listWorktrees,
removeWorktree,
Expand Down Expand Up @@ -189,4 +190,87 @@ describe("worktree operations", () => {
expect(worktrees).toHaveLength(2);
});
});

describe("excludeClaudeDir", () => {
it("excludes .claude from git tracking in worktree after symlink", () => {
// Track .claude in the main repo
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}');
execFileSync("git", ["add", ".claude"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "add .claude"], { cwd: repoDir });

const worktreePath = createWorktree(repoDir, "exclude-test");

// The common git dir's info/exclude should contain .claude
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: worktreePath,
encoding: "utf8",
}).trim();
const resolvedCommonDir = path.isAbsolute(commonDir) ? commonDir : path.resolve(worktreePath, commonDir);
const excludeFile = path.join(resolvedCommonDir, "info", "exclude");

expect(fs.existsSync(excludeFile)).toBe(true);
const excludeContent = fs.readFileSync(excludeFile, "utf8");
expect(excludeContent).toContain(".claude");

// git status should not show .claude as untracked or modified
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: worktreePath,
encoding: "utf8",
});
expect(status).not.toMatch(/\?\? .*\.claude/);
expect(status).not.toMatch(/ M .*\.claude/);
});

it("git operations work after symlinking .claude", () => {
// Track .claude in the main repo
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.mkdirSync(path.join(claudeDir, "commands"));
fs.writeFileSync(path.join(claudeDir, "commands", "test.md"), "test command");
execFileSync("git", ["add", ".claude"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "add .claude"], { cwd: repoDir });

const worktreePath = createWorktree(repoDir, "merge-test");

// Make a commit on main that we'll merge into the worktree
fs.writeFileSync(path.join(repoDir, "new-file.txt"), "new content");
execFileSync("git", ["add", "new-file.txt"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "add new file"], { cwd: repoDir });

// Merge main into the worktree — should not fail with "beyond a symbolic link"
expect(() => {
execFileSync("git", ["merge", "main"], {
cwd: worktreePath,
encoding: "utf8",
stdio: "pipe",
});
}).not.toThrow();

expect(fs.existsSync(path.join(worktreePath, "new-file.txt"))).toBe(true);
});

it("does not duplicate .claude entry in exclude file", () => {
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), "{}");

const worktreePath = createWorktree(repoDir, "no-dup-test");

// Call excludeClaudeDir a second time
excludeClaudeDir(worktreePath);

const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: worktreePath,
encoding: "utf8",
}).trim();
const resolvedCommonDir = path.isAbsolute(commonDir) ? commonDir : path.resolve(worktreePath, commonDir);
const excludeContent = fs.readFileSync(path.join(resolvedCommonDir, "info", "exclude"), "utf8");

// Should appear exactly once
const matches = excludeContent.match(/^\.claude$/gm);
expect(matches).toHaveLength(1);
});
});
});
44 changes: 44 additions & 0 deletions src/main/worktree/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,50 @@ export function symlinkClaudeDir(repoPath: string, worktreePath: string): void {
}

fs.symlinkSync(sourceClaudeDir, targetClaudeDir);
excludeClaudeDir(worktreePath);

// Remove .claude from the worktree's index so git doesn't see
// tracked-file deletions after replacing the directory with a symlink.
// This only affects the worktree's index, not the main repo.
try {
execFileSync("git", ["rm", "--cached", "-r", "--quiet", ".claude"], {
cwd: worktreePath,
stdio: "pipe",
});
} catch {
// .claude may not be tracked — ignore
}
}

/** Add .claude to the worktree's git exclude file so git ignores the symlink.
* Git worktrees read info/exclude from the common git dir (the main repo's .git/). */
export function excludeClaudeDir(worktreePath: string): void {
let commonDir: string;
try {
commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: "pipe",
}).trim();
} catch {
return;
}

// Resolve relative paths (git may return a relative path)
if (!path.isAbsolute(commonDir)) {
commonDir = path.resolve(worktreePath, commonDir);
}

const infoDir = path.join(commonDir, "info");
fs.mkdirSync(infoDir, { recursive: true });

const excludeFile = path.join(infoDir, "exclude");
const existingContent = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";

if (existingContent.split("\n").some((line) => line.trim() === ".claude")) return;

const separator = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : "";
fs.appendFileSync(excludeFile, `${separator}.claude\n`);
}

export function removeWorktree(worktreePath: string, branchName: string, repoPath: string): void {
Expand Down
Loading