diff --git a/cmd/entire/cli/agent/claudecode/worktree_test.go b/cmd/entire/cli/agent/claudecode/worktree_test.go new file mode 100644 index 000000000..5fb05f8a8 --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/worktree_test.go @@ -0,0 +1,47 @@ +package claudecode + +import ( + "testing" +) + +// TestGetSessionDir_WorktreePathDiffers verifies that GetSessionDir produces +// different paths for a main repo vs a worktree under .claude/worktrees/. +// This confirms the root cause of the bug: naive use of WorktreeRoot() as the +// GetSessionDir input produces the wrong project directory in Claude worktrees. +// +// The fix is in cli/transcript.go which now uses paths.MainRepoRoot() instead +// of paths.WorktreeRoot() to resolve the main repo path before calling GetSessionDir. +func TestGetSessionDir_WorktreePathDiffers(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{} + + // Simulate paths that WorktreeRoot would return + mainRepo := "/home/user/my-project" + worktree := "/home/user/my-project/.claude/worktrees/feature-branch" + + mainDir, err := ag.GetSessionDir(mainRepo) + if err != nil { + t.Fatalf("GetSessionDir(mainRepo) error: %v", err) + } + + worktreeDir, err := ag.GetSessionDir(worktree) + if err != nil { + t.Fatalf("GetSessionDir(worktree) error: %v", err) + } + + // These MUST differ, proving that GetSessionDir cannot be called with + // a worktree path when the intent is to find the main repo's project dir. + if mainDir == worktreeDir { + t.Fatalf("expected different session dirs for main repo vs worktree paths, but both are %q", mainDir) + } + + // Verify the worktree path contains the extra segments + expectedMainSanitized := SanitizePathForClaude(mainRepo) + expectedWTSanitized := SanitizePathForClaude(worktree) + + if expectedMainSanitized == expectedWTSanitized { + t.Fatalf("sanitized paths should differ: main=%q, worktree=%q", + expectedMainSanitized, expectedWTSanitized) + } +} diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index f22617249..f96f1183c 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -118,6 +118,63 @@ func ClearWorktreeRootCache() { worktreeRootMu.Unlock() } +// IsLinkedWorktree returns true if the given path is inside a linked git worktree +// (as opposed to the main repository or a submodule). Linked worktrees have .git +// as a file whose gitdir points into a worktree admin dir (.git/worktrees/ or +// .bare/worktrees/). Submodules also have .git as a file but point into +// .git/modules/, so they are not treated as linked worktrees. +func IsLinkedWorktree(worktreeRoot string) bool { + gitdir, err := parseGitfile(worktreeRoot) + if err != nil || gitdir == "" { + return false + } + return hasWorktreeMarker(gitdir) +} + +// MainRepoRoot returns the root directory of the main repository. +// In the main repo, this returns the same as WorktreeRoot. +// In a linked worktree, this parses the .git file to find the main repo root. +// Supports both standard (.git/worktrees/) and bare-repo (.bare/worktrees/) layouts, +// and handles relative gitdir paths. +// +// Per gitrepository-layout(5), a worktree's .git file is a "gitfile" containing +// "gitdir: " pointing to $GIT_DIR/worktrees/ in the main repository. +// See: https://git-scm.com/docs/gitrepository-layout +func MainRepoRoot(ctx context.Context) (string, error) { + worktreeRoot, err := WorktreeRoot(ctx) + if err != nil { + return "", fmt.Errorf("failed to get worktree path: %w", err) + } + + gitdir, err := parseGitfile(worktreeRoot) + if err != nil { + return "", err + } + + // Main worktree or non-worktree gitfile (e.g. submodule): return as-is. + if gitdir == "" || !hasWorktreeMarker(gitdir) { + return worktreeRoot, nil + } + + // Extract main repo root: everything before the worktree marker. + for _, marker := range worktreeMarkers { + if idx := strings.LastIndex(gitdir, marker); idx >= 0 { + // marker includes the trailing slash of the git dir (e.g. ".git/worktrees/"), + // so we need to trim the leading dir separator + marker prefix. + // For ".git/worktrees/", the repo root is everything before "/.git/worktrees/". + // The marker without the admin-dir prefix is "/worktrees/", but we want + // the path before the git-dir itself, so find the git-dir boundary. + gitDirSuffix := strings.TrimSuffix(marker, "worktrees/") // ".git/" or ".bare/" + gitDirIdx := strings.LastIndex(gitdir, "/"+gitDirSuffix) + if gitDirIdx >= 0 { + return filepath.FromSlash(gitdir[:gitDirIdx]), nil + } + } + } + + return "", fmt.Errorf("unexpected gitdir format: %s", gitdir) +} + // AbsPath returns the absolute path for a relative path within the repository. // If the path is already absolute, it is returned as-is. // Uses WorktreeRoot() to resolve paths relative to the worktree root. diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index 31c3bd195..d36bd0749 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -1,9 +1,13 @@ package paths import ( + "context" "os" + "os/exec" "path/filepath" "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" ) func TestIsSubpath(t *testing.T) { @@ -102,6 +106,174 @@ func TestGetClaudeProjectDir_Override(t *testing.T) { } } +func TestMainRepoRoot_MainRepo(t *testing.T) { + // Cannot use t.Parallel: uses t.Chdir + tmpDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + tmpDir = resolved + + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + + ClearWorktreeRootCache() + t.Chdir(tmpDir) + + root, err := MainRepoRoot(context.Background()) + if err != nil { + t.Fatalf("MainRepoRoot() error: %v", err) + } + if root != tmpDir { + t.Errorf("MainRepoRoot() = %q, want %q", root, tmpDir) + } +} + +func TestMainRepoRoot_LinkedWorktree(t *testing.T) { + // Cannot use t.Parallel: uses t.Chdir + tmpDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + tmpDir = resolved + + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + + // Create linked worktree + worktreeDir := filepath.Join(tmpDir, ".claude", "worktrees", "test-branch") + if err := os.MkdirAll(filepath.Dir(worktreeDir), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + cmd := exec.Command("git", "worktree", "add", "-b", "test-branch", worktreeDir) //nolint:noctx // test code + cmd.Dir = tmpDir + cmd.Env = testutil.GitIsolatedEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git worktree add: %v\n%s", err, output) + } + + ClearWorktreeRootCache() + t.Chdir(worktreeDir) + + root, err := MainRepoRoot(context.Background()) + if err != nil { + t.Fatalf("MainRepoRoot() error: %v", err) + } + if root != tmpDir { + t.Errorf("MainRepoRoot() = %q, want %q (should resolve to main repo, not worktree)", root, tmpDir) + } +} + +func TestMainRepoRoot_Submodule(t *testing.T) { + // Cannot use t.Parallel: uses t.Chdir + // MainRepoRoot should return the submodule root (not the superproject) + // when running from inside a submodule. + superDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(superDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + superDir = resolved + + // Create the "library" repo that will become a submodule + libDir := t.TempDir() + testutil.InitRepo(t, libDir) + testutil.WriteFile(t, libDir, "lib.txt", "lib") + testutil.GitAdd(t, libDir, "lib.txt") + testutil.GitCommit(t, libDir, "lib init") + + // Create the superproject + testutil.InitRepo(t, superDir) + testutil.WriteFile(t, superDir, "main.txt", "main") + testutil.GitAdd(t, superDir, "main.txt") + testutil.GitCommit(t, superDir, "super init") + + // Add submodule (allow file transport for local clone) + cmd := exec.Command("git", "-c", "protocol.file.allow=always", "submodule", "add", libDir, "libs/mylib") //nolint:noctx // test code + cmd.Dir = superDir + cmd.Env = testutil.GitIsolatedEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git submodule add: %v\n%s", err, output) + } + + submoduleDir := filepath.Join(superDir, "libs", "mylib") + + ClearWorktreeRootCache() + t.Chdir(submoduleDir) + + // MainRepoRoot should return the submodule root, not the superproject + root, err := MainRepoRoot(context.Background()) + if err != nil { + t.Fatalf("MainRepoRoot() error: %v", err) + } + if root != submoduleDir { + t.Errorf("MainRepoRoot() = %q, want %q (should stay in submodule, not escape to superproject)", root, submoduleDir) + } +} + +func TestIsLinkedWorktree(t *testing.T) { + t.Parallel() + + t.Run("main repo", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if IsLinkedWorktree(dir) { + t.Error("IsLinkedWorktree() = true for main repo, want false") + } + }) + + t.Run("linked worktree", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /repo/.git/worktrees/wt\n"), 0o644); err != nil { + t.Fatal(err) + } + if !IsLinkedWorktree(dir) { + t.Error("IsLinkedWorktree() = false for linked worktree, want true") + } + }) + + t.Run("bare repo worktree", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /repo/.bare/worktrees/main\n"), 0o644); err != nil { + t.Fatal(err) + } + if !IsLinkedWorktree(dir) { + t.Error("IsLinkedWorktree() = false for bare repo worktree, want true") + } + }) + + t.Run("submodule", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Submodules have .git as a file pointing into .git/modules/, not .git/worktrees/ + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /repo/.git/modules/mylib\n"), 0o644); err != nil { + t.Fatal(err) + } + if IsLinkedWorktree(dir) { + t.Error("IsLinkedWorktree() = true for submodule, want false") + } + }) + + t.Run("no git", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if IsLinkedWorktree(dir) { + t.Error("IsLinkedWorktree() = true for dir without .git, want false") + } + }) +} + func TestGetClaudeProjectDir_Default(t *testing.T) { // Ensure env var is not set by setting it to empty string t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", "") diff --git a/cmd/entire/cli/paths/worktree.go b/cmd/entire/cli/paths/worktree.go index 6e0f3c3e6..10b76be96 100644 --- a/cmd/entire/cli/paths/worktree.go +++ b/cmd/entire/cli/paths/worktree.go @@ -5,16 +5,26 @@ import ( "os" "path/filepath" "strings" + + "github.com/entireio/cli/cmd/entire/cli/osroot" ) -// GetWorktreeID returns the internal git worktree identifier for the given path. -// For the main worktree (where .git is a directory), returns empty string. -// For linked worktrees (where .git is a file), extracts the name from -// .git/worktrees// path. This name is stable across `git worktree move`. -func GetWorktreeID(worktreePath string) (string, error) { - gitPath := filepath.Join(worktreePath, ".git") +// worktreeMarkers are the path segments that identify a git worktree admin +// directory inside the gitdir value. Both standard (.git/worktrees/) and +// bare-repo (.bare/worktrees/) layouts are supported. +var worktreeMarkers = []string{".git/worktrees/", ".bare/worktrees/"} + +// parseGitfile reads a .git gitfile via os.Root and returns the raw gitdir value. +// Returns empty string and no error if .git is a directory (main worktree). +// Returns an error if .git doesn't exist or cannot be read. +func parseGitfile(worktreePath string) (string, error) { + root, err := os.OpenRoot(worktreePath) + if err != nil { + return "", fmt.Errorf("failed to open path: %w", err) + } + defer root.Close() - info, err := os.Stat(gitPath) + info, err := root.Stat(".git") if err != nil { return "", fmt.Errorf("failed to stat .git: %w", err) } @@ -24,8 +34,7 @@ func GetWorktreeID(worktreePath string) (string, error) { return "", nil } - // Linked worktree has .git as a file with content: "gitdir: /path/to/.git/worktrees/" - content, err := os.ReadFile(gitPath) //nolint:gosec // gitPath is constructed from worktreePath + ".git" + content, err := osroot.ReadFile(root, ".git") if err != nil { return "", fmt.Errorf("failed to read .git file: %w", err) } @@ -37,12 +46,53 @@ func GetWorktreeID(worktreePath string) (string, error) { gitdir := strings.TrimPrefix(line, "gitdir: ") + // Resolve relative gitdir paths against the worktree root. + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(worktreePath, gitdir) + } + gitdir = filepath.Clean(gitdir) + + // Normalize to forward slashes for consistent marker matching. + gitdir = filepath.ToSlash(gitdir) + + return gitdir, nil +} + +// hasWorktreeMarker reports whether a gitdir value contains a worktree admin +// marker (e.g. ".git/worktrees/" or ".bare/worktrees/"). This distinguishes +// linked worktrees from submodules and other gitfile-based layouts. +func hasWorktreeMarker(gitdir string) bool { + for _, marker := range worktreeMarkers { + if strings.Contains(gitdir, marker) { + return true + } + } + return false +} + +// GetWorktreeID returns the internal git worktree identifier for the given path. +// For the main worktree (where .git is a directory), returns empty string. +// For linked worktrees (where .git is a file pointing into a worktree admin dir), +// extracts the name from the .git/worktrees// path. This name is stable +// across `git worktree move`. +// Returns an error for non-worktree gitfiles (e.g. submodules). +// Uses os.Root for traversal-resistant access. +func GetWorktreeID(worktreePath string) (string, error) { + gitdir, err := parseGitfile(worktreePath) + if err != nil { + return "", err + } + + // Main worktree: .git is a directory, gitdir is empty. + if gitdir == "" { + return "", nil + } + // Extract worktree name from path like /repo/.git/worktrees/ // or /repo/.bare/worktrees/ (bare repo + worktree layout). - // The path after the marker is the worktree identifier. var worktreeID string var found bool - for _, marker := range []string{".git/worktrees/", ".bare/worktrees/"} { + for _, marker := range worktreeMarkers { _, worktreeID, found = strings.Cut(gitdir, marker) if found { break diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index b8a9b8d7d..fc0ea631c 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -835,18 +835,11 @@ func OpenRepository(ctx context.Context) (*git.Repository, error) { // to the main repo, while the main repo has .git as a directory. // This function works correctly from any subdirectory within the repository. func IsInsideWorktree(ctx context.Context) bool { - // First find the repository root repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { return false } - - gitPath := filepath.Join(repoRoot, gitDir) - gitInfo, err := os.Stat(gitPath) - if err != nil { - return false - } - return !gitInfo.IsDir() + return paths.IsLinkedWorktree(repoRoot) } // GetMainRepoRoot returns the root directory of the main repository. @@ -854,36 +847,13 @@ func IsInsideWorktree(ctx context.Context) bool { // In a worktree, this parses the .git file to find the main repo. // This function works correctly from any subdirectory within the repository. // -// Per gitrepository-layout(5), a worktree's .git file is a "gitfile" containing -// "gitdir: " pointing to $GIT_DIR/worktrees/ in the main repository. -// See: https://git-scm.com/docs/gitrepository-layout +// Delegates to paths.MainRepoRoot. func GetMainRepoRoot(ctx context.Context) (string, error) { - // First find the worktree/repo root - repoRoot, err := paths.WorktreeRoot(ctx) + root, err := paths.MainRepoRoot(ctx) if err != nil { - return "", fmt.Errorf("failed to get worktree path: %w", err) - } - - if !IsInsideWorktree(ctx) { - return repoRoot, nil - } - - // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/" - gitFilePath := filepath.Join(repoRoot, gitDir) - content, err := os.ReadFile(gitFilePath) //nolint:gosec // G304: gitFilePath is constructed from repo root, not user input - if err != nil { - return "", fmt.Errorf("failed to read .git file: %w", err) - } - - gitdir := strings.TrimSpace(string(content)) - gitdir = strings.TrimPrefix(gitdir, "gitdir: ") - - // Extract main repo root: everything before "/.git/" - idx := strings.LastIndex(gitdir, "/.git/") - if idx < 0 { - return "", fmt.Errorf("unexpected gitdir format: %s", gitdir) + return "", fmt.Errorf("failed to get main repo root: %w", err) } - return gitdir[:idx], nil + return root, nil } // GetGitCommonDir returns the path to the shared git directory. diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index 3bb5d9587..e05d66114 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -21,10 +21,12 @@ const ( // resolveTranscriptPath determines the correct file path for an agent's session transcript. // Computes the path dynamically from the current repo location for cross-machine portability. +// Uses MainRepoRoot (not WorktreeRoot) so that linked worktrees (e.g., Claude Code +// worktrees under .claude/worktrees/) resolve to the same project directory as the main repo. func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg.Agent) (string, error) { - repoRoot, err := paths.WorktreeRoot(ctx) + repoRoot, err := paths.MainRepoRoot(ctx) if err != nil { - return "", fmt.Errorf("failed to get worktree root: %w", err) + return "", fmt.Errorf("failed to get main repo root: %w", err) } sessionDir, err := agent.GetSessionDir(repoRoot)