From b13d1b0e644fc988e9a5ec8ad5ed502c0ea606d2 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Wed, 25 Mar 2026 08:01:39 +0100 Subject: [PATCH] fix: exclude .claude from git tracking in worktrees When Codez replaces .claude/ with a symlink in worktrees, git operations (merge, stash, checkout) fail with "beyond a symbolic link" errors. Fix by adding .claude to the repo's info/exclude and removing it from the worktree's index after symlinking. Co-Authored-By: Claude Opus 4.6 --- src/main/worktree/worktree-manager.test.ts | 84 ++++++++++++++++++++++ src/main/worktree/worktree-manager.ts | 44 ++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/main/worktree/worktree-manager.test.ts b/src/main/worktree/worktree-manager.test.ts index 46c8290..2d5a631 100644 --- a/src/main/worktree/worktree-manager.test.ts +++ b/src/main/worktree/worktree-manager.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createWorktree, + excludeClaudeDir, generateWorktreePath, listWorktrees, removeWorktree, @@ -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); + }); + }); }); diff --git a/src/main/worktree/worktree-manager.ts b/src/main/worktree/worktree-manager.ts index 9d149ab..62b74d8 100644 --- a/src/main/worktree/worktree-manager.ts +++ b/src/main/worktree/worktree-manager.ts @@ -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 {