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
12 changes: 7 additions & 5 deletions e2e/worktree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,15 @@ test("worktreeBaseDir setting directs worktrees to a custom location", async ()
expect(worktreeList).toContain("custom-loc");
expect(worktreeList).toContain(expectedPath);

// .claude should be symlinked from the main repo into the custom worktree
// .claude should be a real directory (not a symlink) so git works normally
const worktreeClaudeDir = path.join(expectedPath, ".claude");
expect(existsSync(worktreeClaudeDir)).toBe(true);
expect(lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(true);
expect(realpathSync(worktreeClaudeDir)).toBe(realpathSync(claudeDir));
// Permissions file is accessible through the symlink
expect(existsSync(path.join(worktreeClaudeDir, "settings.local.json"))).toBe(true);
expect(lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(false);
expect(lstatSync(worktreeClaudeDir).isDirectory()).toBe(true);
// settings.local.json should be symlinked from the main repo for shared permissions
const worktreeSettingsJson = path.join(worktreeClaudeDir, "settings.local.json");
expect(lstatSync(worktreeSettingsJson).isSymbolicLink()).toBe(true);
expect(realpathSync(worktreeSettingsJson)).toBe(realpathSync(path.join(claudeDir, "settings.local.json")));
});

test("creating a session without a branch uses the repo directly", async () => {
Expand Down
72 changes: 59 additions & 13 deletions src/main/worktree/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createWorktree,
generateWorktreePath,
getDefaultBranch,
linkClaudeSettings,
listLocalBranches,
listWorktrees,
removeWorktree,
Expand Down Expand Up @@ -143,39 +144,84 @@ describe("worktree operations", () => {
expect(() => createWorktree(repoDir, "existing-branch")).toThrow();
});

it("symlinks .claude directory from main repo into worktree", () => {
it("symlinks settings.local.json from main repo into worktree .claude dir", () => {
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}');

const worktreePath = createWorktree(repoDir, "with-claude");
const worktreeClaudeDir = path.join(worktreePath, ".claude");

expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(worktreeClaudeDir)).toBe(claudeDir);
// Permissions file is accessible through the symlink
expect(fs.existsSync(path.join(worktreeClaudeDir, "settings.local.json"))).toBe(true);
const worktreeSettingsJson = path.join(worktreeClaudeDir, "settings.local.json");

// .claude/ is a real directory, not a symlink
expect(fs.lstatSync(worktreeClaudeDir).isDirectory()).toBe(true);
expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(false);
// settings.local.json is a symlink pointing to the main repo's copy
expect(fs.lstatSync(worktreeSettingsJson).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(worktreeSettingsJson)).toBe(path.join(claudeDir, "settings.local.json"));
});

it("replaces tracked .claude directory with symlink when .claude is committed", () => {
// .claude/ is tracked by git — git worktree add copies it into the worktree
it("keeps .claude as real directory so tracked files are git-manageable", () => {
// .claude/ is committed — worktree gets a real copy from git
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}');
fs.mkdirSync(path.join(claudeDir, "commands"));
fs.writeFileSync(path.join(claudeDir, "commands", "foo.md"), "# foo");
execFileSync("git", ["add", ".claude"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "add .claude"], { cwd: repoDir });

const worktreePath = createWorktree(repoDir, "tracked-claude");
const worktreeClaudeDir = path.join(worktreePath, ".claude");

// Should be a symlink, not a copied directory
expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(worktreeClaudeDir)).toBe(claudeDir);
// .claude/ must be a real directory so the worktree can add/commit files in it
expect(fs.lstatSync(worktreeClaudeDir).isDirectory()).toBe(true);
expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(false);
// Committed files are present
expect(fs.existsSync(path.join(worktreeClaudeDir, "commands", "foo.md"))).toBe(true);
});

it("skips symlink when main repo has no .claude directory", () => {
it("skips when main repo has no settings.local.json", () => {
const worktreePath = createWorktree(repoDir, "no-claude");
expect(fs.existsSync(path.join(worktreePath, ".claude"))).toBe(false);
// No .claude/ created when source settings.local.json doesn't exist
expect(fs.existsSync(path.join(worktreePath, ".claude", "settings.local.json"))).toBe(false);
});

it("migrates old directory symlink to real directory with file symlink", () => {
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}');

// Simulate old behaviour: .claude is a directory symlink in the worktree
const worktreePath = createWorktree(repoDir, "migrate-test");
const worktreeClaudeDir = path.join(worktreePath, ".claude");
// Manually install the old symlink style
if (fs.existsSync(worktreeClaudeDir)) fs.rmSync(worktreeClaudeDir, { recursive: true });
fs.symlinkSync(claudeDir, worktreeClaudeDir);
expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(true);

// Re-running linkClaudeSettings should migrate it
linkClaudeSettings(repoDir, worktreePath);

expect(fs.lstatSync(worktreeClaudeDir).isDirectory()).toBe(true);
expect(fs.lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(false);
expect(fs.lstatSync(path.join(worktreeClaudeDir, "settings.local.json")).isSymbolicLink()).toBe(true);
});

it("leaves an existing real settings.local.json file untouched", () => {
const claudeDir = path.join(repoDir, ".claude");
fs.mkdirSync(claudeDir);
fs.writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}');
// Commit it so the worktree gets a real copy
execFileSync("git", ["add", ".claude"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "add .claude"], { cwd: repoDir });

const worktreePath = createWorktree(repoDir, "real-settings");
const worktreeSettings = path.join(worktreePath, ".claude", "settings.local.json");

// Should be a real file (from git checkout), not replaced with symlink
expect(fs.lstatSync(worktreeSettings).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(worktreeSettings, "utf8")).toBe('{"permissions":{}}');
});

it("creates worktree under custom base directory when provided", () => {
Expand Down
52 changes: 39 additions & 13 deletions src/main/worktree/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,27 +111,53 @@ export function createWorktree(
throw new Error(`Failed to create worktree: ${message}`);
}

symlinkClaudeDir(options.repoPath, worktreePath);
linkClaudeSettings(options.repoPath, worktreePath);

return worktreePath;
}

/** Symlink .claude/ from the main repo into a worktree so permissions are shared */
export function symlinkClaudeDir(repoPath: string, worktreePath: string): void {
const sourceClaudeDir = path.join(repoPath, ".claude");
if (!fs.existsSync(sourceClaudeDir)) return;
/**
* Symlink settings.local.json from the main repo into a worktree so tool
* permissions are shared, while leaving .claude/ as a real directory so git
* stash and normal git operations work without issues.
*/
export function linkClaudeSettings(repoPath: string, worktreePath: string): void {
const sourceSettingsJson = path.join(repoPath, ".claude", "settings.local.json");
if (!fs.existsSync(sourceSettingsJson)) return;

const targetClaudeDir = path.join(worktreePath, ".claude");
const worktreeClaudeDir = path.join(worktreePath, ".claude");

// If .claude already exists in the worktree (e.g. tracked by git),
// remove it first so we can replace it with a symlink
if (fs.existsSync(targetClaudeDir)) {
const stat = fs.lstatSync(targetClaudeDir);
if (stat.isSymbolicLink()) return; // already a symlink — nothing to do
fs.rmSync(targetClaudeDir, { recursive: true, force: true });
// Migrate from old behaviour: .claude/ was a directory symlink.
// Remove it and restore any tracked files so .claude/ becomes a real directory.
try {
const stat = fs.lstatSync(worktreeClaudeDir);
if (stat.isSymbolicLink()) {
fs.rmSync(worktreeClaudeDir);
try {
execFileSync("git", ["checkout", "--", ".claude"], {
cwd: worktreePath,
encoding: "utf8",
stdio: "pipe",
});
} catch {
// .claude is not tracked — just recreate the directory
fs.mkdirSync(worktreeClaudeDir);
}
}
// else: already a real directory — nothing to do
} catch {
// lstatSync threw — path doesn't exist, create the directory
fs.mkdirSync(worktreeClaudeDir);
}

fs.symlinkSync(sourceClaudeDir, targetClaudeDir);
// Symlink settings.local.json if it isn't already there
const targetSettingsJson = path.join(worktreeClaudeDir, "settings.local.json");
try {
fs.lstatSync(targetSettingsJson);
// Already exists (real file or symlink) — leave it alone
} catch {
fs.symlinkSync(sourceSettingsJson, targetSettingsJson);
}
}

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