From 9b3686f1bc1a8b12ca9614f23bd16bf0d3284926 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Tue, 31 Mar 2026 11:27:03 +0200 Subject: [PATCH 1/2] fix: symlink only settings.local.json, keep .claude as real directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes git stash failures when Claude Code runs in worktrees by ensuring .claude/ is a real directory that git can track normally. Changes: - Rename symlinkClaudeDir → linkClaudeSettings for clarity - Only symlink settings.local.json (for shared tool permissions) - Leave .claude/ as real directory (allows git to track files like commands/, settings.json, CLAUDE.md per-branch) - Add migration for old directory-symlink worktrees - Update tests to verify new behavior This eliminates git stash errors caused by directory symlinks while maintaining permission-sharing across worktrees. Co-Authored-By: Claude Sonnet 4.6 --- src/main/worktree/worktree-manager.test.ts | 72 ++++++++++++++++++---- src/main/worktree/worktree-manager.ts | 52 ++++++++++++---- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/src/main/worktree/worktree-manager.test.ts b/src/main/worktree/worktree-manager.test.ts index 0520591..a5736da 100644 --- a/src/main/worktree/worktree-manager.test.ts +++ b/src/main/worktree/worktree-manager.test.ts @@ -7,6 +7,7 @@ import { createWorktree, generateWorktreePath, getDefaultBranch, + linkClaudeSettings, listLocalBranches, listWorktrees, removeWorktree, @@ -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", () => { diff --git a/src/main/worktree/worktree-manager.ts b/src/main/worktree/worktree-manager.ts index 41f2ba1..c56425c 100644 --- a/src/main/worktree/worktree-manager.ts +++ b/src/main/worktree/worktree-manager.ts @@ -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 { From 20ecb0c9ac2bc24f2f841ad3db2008190c0f9b08 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Tue, 31 Mar 2026 11:29:48 +0200 Subject: [PATCH 2/2] fix: update e2e test to match new .claude linking behavior Co-Authored-By: Claude Sonnet 4.6 --- e2e/worktree.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/e2e/worktree.spec.ts b/e2e/worktree.spec.ts index 320d72c..04b685f 100644 --- a/e2e/worktree.spec.ts +++ b/e2e/worktree.spec.ts @@ -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 () => {