Skip to content
Open
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
47 changes: 47 additions & 0 deletions cmd/entire/cli/agent/claudecode/worktree_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
57 changes: 57 additions & 0 deletions cmd/entire/cli/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>" pointing to $GIT_DIR/worktrees/<id> 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.
Expand Down
172 changes: 172 additions & 0 deletions cmd/entire/cli/paths/paths_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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", "")
Expand Down
Loading
Loading