From ea0d5ff42f99cf290e14c32f6285428b816b9496 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Tue, 31 Mar 2026 12:17:28 +0100 Subject: [PATCH 1/2] Fix transcript path resolution for Claude Code worktrees When Claude Code uses --worktree, it creates git worktrees under .claude/worktrees//. Entire's resolveTranscriptPath used WorktreeRoot() which returned the worktree path, causing GetSessionDir to derive a different Claude project directory than the main repo. This broke rewind and resume operations. Add paths.MainRepoRoot() to resolve back to the main repository root from linked worktrees, and use it in cli.resolveTranscriptPath. Refactor GetWorktreeID, IsLinkedWorktree, and MainRepoRoot to use os.Root for traversal-resistant .git file access. Signed-off-by: Paulo Gomes Assisted-by: Assisted-by: Claude Opus 4.6 Entire-Checkpoint: c6491956c19f --- .../cli/agent/claudecode/worktree_test.go | 47 ++++++++ cmd/entire/cli/paths/paths.go | 60 +++++++++++ cmd/entire/cli/paths/paths_test.go | 102 ++++++++++++++++++ cmd/entire/cli/paths/worktree.go | 14 ++- cmd/entire/cli/strategy/common.go | 40 +------ cmd/entire/cli/transcript.go | 6 +- 6 files changed, 228 insertions(+), 41 deletions(-) create mode 100644 cmd/entire/cli/agent/claudecode/worktree_test.go 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..3380d9080 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/osroot" ) // Directory constants @@ -118,6 +119,65 @@ func ClearWorktreeRootCache() { worktreeRootMu.Unlock() } +// IsLinkedWorktree returns true if the given path is inside a linked git worktree +// (as opposed to the main repository). Linked worktrees have .git as a file +// pointing to the main repo, while the main repo has .git as a directory. +// Uses os.Root for traversal-resistant access. +func IsLinkedWorktree(worktreeRoot string) bool { + root, err := os.OpenRoot(worktreeRoot) + if err != nil { + return false + } + defer root.Close() + + info, err := root.Stat(".git") + if err != nil { + return false + } + return !info.IsDir() +} + +// 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. +// Uses os.Root for traversal-resistant reads. +// +// 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) + } + + if !IsLinkedWorktree(worktreeRoot) { + return worktreeRoot, nil + } + + root, err := os.OpenRoot(worktreeRoot) + if err != nil { + return "", fmt.Errorf("failed to open worktree root: %w", err) + } + defer root.Close() + + // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/" + content, err := osroot.ReadFile(root, ".git") + 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 gitdir[:idx], nil +} + // 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..affd8d426 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,104 @@ 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", worktreeDir, "-b", "test-branch") //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 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("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..c5bd77183 100644 --- a/cmd/entire/cli/paths/worktree.go +++ b/cmd/entire/cli/paths/worktree.go @@ -3,18 +3,24 @@ package paths import ( "fmt" "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`. +// Uses os.Root for traversal-resistant access. func GetWorktreeID(worktreePath string) (string, error) { - gitPath := filepath.Join(worktreePath, ".git") + root, err := os.OpenRoot(worktreePath) + if err != nil { + return "", fmt.Errorf("failed to open worktree 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) } @@ -25,7 +31,7 @@ func GetWorktreeID(worktreePath string) (string, error) { } // 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) } 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) From c404ef1fafbc8e7f6eb133520c9010e4913b763b Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Tue, 31 Mar 2026 12:48:04 +0100 Subject: [PATCH 2/2] Harden worktree detection against submodules and bare repos IsLinkedWorktree now reads the .git gitfile and checks for worktree admin markers (.git/worktrees/ or .bare/worktrees/) instead of just checking whether .git is a file. This prevents submodules (which use .git/modules/) from being misidentified as linked worktrees. MainRepoRoot now supports both .git/worktrees/ and .bare/worktrees/ markers when extracting the main repo root, and handles relative gitdir paths by resolving them against the worktree root. The shared parseGitfile helper normalizes paths with filepath.ToSlash for consistent marker matching. Extract parseGitfile and hasWorktreeMarker into shared helpers used by GetWorktreeID, IsLinkedWorktree, and MainRepoRoot. Fix git worktree add argument order in tests (-b before path). Signed-off-by: Paulo Gomes Assisted-by: Assisted-by: Claude Opus 4.6 Entire-Checkpoint: f6d95094dfb9 --- cmd/entire/cli/paths/paths.go | 63 +++++++++++++------------- cmd/entire/cli/paths/paths_test.go | 72 +++++++++++++++++++++++++++++- cmd/entire/cli/paths/worktree.go | 64 +++++++++++++++++++++----- 3 files changed, 155 insertions(+), 44 deletions(-) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 3380d9080..f96f1183c 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -11,7 +11,6 @@ import ( "sync" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/osroot" ) // Directory constants @@ -120,27 +119,23 @@ func ClearWorktreeRootCache() { } // IsLinkedWorktree returns true if the given path is inside a linked git worktree -// (as opposed to the main repository). Linked worktrees have .git as a file -// pointing to the main repo, while the main repo has .git as a directory. -// Uses os.Root for traversal-resistant access. +// (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 { - root, err := os.OpenRoot(worktreeRoot) - if err != nil { - return false - } - defer root.Close() - - info, err := root.Stat(".git") - if err != nil { + gitdir, err := parseGitfile(worktreeRoot) + if err != nil || gitdir == "" { return false } - return !info.IsDir() + 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. -// Uses os.Root for traversal-resistant reads. +// 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. @@ -151,31 +146,33 @@ func MainRepoRoot(ctx context.Context) (string, error) { return "", fmt.Errorf("failed to get worktree path: %w", err) } - if !IsLinkedWorktree(worktreeRoot) { - return worktreeRoot, nil - } - - root, err := os.OpenRoot(worktreeRoot) + gitdir, err := parseGitfile(worktreeRoot) if err != nil { - return "", fmt.Errorf("failed to open worktree root: %w", err) + return "", err } - defer root.Close() - // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/" - content, err := osroot.ReadFile(root, ".git") - if err != nil { - return "", fmt.Errorf("failed to read .git file: %w", err) + // Main worktree or non-worktree gitfile (e.g. submodule): return as-is. + if gitdir == "" || !hasWorktreeMarker(gitdir) { + return worktreeRoot, nil } - 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) + // 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 gitdir[:idx], nil + + return "", fmt.Errorf("unexpected gitdir format: %s", gitdir) } // AbsPath returns the absolute path for a relative path within the repository. diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index affd8d426..d36bd0749 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -151,7 +151,7 @@ func TestMainRepoRoot_LinkedWorktree(t *testing.T) { if err := os.MkdirAll(filepath.Dir(worktreeDir), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } - cmd := exec.Command("git", "worktree", "add", worktreeDir, "-b", "test-branch") //nolint:noctx // test code + 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 { @@ -170,6 +170,53 @@ func TestMainRepoRoot_LinkedWorktree(t *testing.T) { } } +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() @@ -195,6 +242,29 @@ func TestIsLinkedWorktree(t *testing.T) { } }) + 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() diff --git a/cmd/entire/cli/paths/worktree.go b/cmd/entire/cli/paths/worktree.go index c5bd77183..10b76be96 100644 --- a/cmd/entire/cli/paths/worktree.go +++ b/cmd/entire/cli/paths/worktree.go @@ -3,20 +3,24 @@ package paths import ( "fmt" "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`. -// Uses os.Root for traversal-resistant access. -func GetWorktreeID(worktreePath string) (string, error) { +// 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 worktree path: %w", err) + return "", fmt.Errorf("failed to open path: %w", err) } defer root.Close() @@ -30,7 +34,6 @@ func GetWorktreeID(worktreePath string) (string, error) { return "", nil } - // Linked worktree has .git as a file with content: "gitdir: /path/to/.git/worktrees/" content, err := osroot.ReadFile(root, ".git") if err != nil { return "", fmt.Errorf("failed to read .git file: %w", err) @@ -43,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