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 0000000000..5fb05f8a89 --- /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 3c96f383e2..c5d6ed983d 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -124,6 +124,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 b442c660a4..38f2a43f30 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -1,10 +1,14 @@ package paths import ( + "context" "os" + "os/exec" "path/filepath" "runtime" "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" ) func TestIsSubpath(t *testing.T) { @@ -103,6 +107,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 6e0f3c3e62..10b76be967 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 a96b4b2573..78c6b68677 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -880,18 +880,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. @@ -899,36 +892,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 bb52cb07a7..2f142f87d3 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -24,6 +24,21 @@ func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg return "", fmt.Errorf("failed to get worktree root: %w", err) } + // Claude's built-in --worktree mode creates nested worktrees under + // /.claude/worktrees/, but session transcripts stay keyed to + // the main repo project path. User-created git worktrees live elsewhere and + // should continue to use their own worktree path. + if agent.Name() == agentpkg.AgentNameClaudeCode { + mainRepoRoot, mainErr := paths.MainRepoRoot(ctx) + if mainErr != nil { + return "", fmt.Errorf("failed to get main repo root: %w", mainErr) + } + claudeWorktreesDir := filepath.Join(mainRepoRoot, ".claude", "worktrees") + if paths.IsSubpath(claudeWorktreesDir, repoRoot) { + repoRoot = mainRepoRoot + } + } + sessionDir, err := agent.GetSessionDir(repoRoot) if err != nil { return "", fmt.Errorf("failed to get agent session directory: %w", err) diff --git a/cmd/entire/cli/transcript_test.go b/cmd/entire/cli/transcript_test.go index 6176cb6f78..b2a8fbe1f3 100644 --- a/cmd/entire/cli/transcript_test.go +++ b/cmd/entire/cli/transcript_test.go @@ -1,9 +1,17 @@ package cli import ( + "context" "os" + "os/exec" "path/filepath" "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" ) func createTempTranscript(t *testing.T, content string) string { @@ -16,6 +24,73 @@ func createTempTranscript(t *testing.T, content string) string { return tmpFile } +type pathSensitiveTestAgent struct{} + +var _ agent.Agent = (*pathSensitiveTestAgent)(nil) + +func (a *pathSensitiveTestAgent) Name() types.AgentName { return "path-sensitive-test" } +func (a *pathSensitiveTestAgent) Type() types.AgentType { return "Path Sensitive Test Agent" } +func (a *pathSensitiveTestAgent) Description() string { + return "Test agent whose session dir depends on the exact repo path" +} +func (a *pathSensitiveTestAgent) IsPreview() bool { return false } +func (a *pathSensitiveTestAgent) DetectPresence(_ context.Context) (bool, error) { return true, nil } +func (a *pathSensitiveTestAgent) ProtectedDirs() []string { return nil } +func (a *pathSensitiveTestAgent) GetSessionID(_ *agent.HookInput) string { return "" } +func (a *pathSensitiveTestAgent) ReadTranscript(_ string) ([]byte, error) { return nil, nil } +func (a *pathSensitiveTestAgent) ChunkTranscript(_ context.Context, content []byte, _ int) ([][]byte, error) { + return [][]byte{content}, nil +} +func (a *pathSensitiveTestAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + var result []byte + for _, c := range chunks { + result = append(result, c...) + } + return result, nil +} +func (a *pathSensitiveTestAgent) GetSessionDir(repoPath string) (string, error) { + return filepath.Join(repoPath, ".agent-sessions"), nil +} +func (a *pathSensitiveTestAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} +func (a *pathSensitiveTestAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) { + return nil, nil //nolint:nilnil // test mock +} +func (a *pathSensitiveTestAgent) WriteSession(_ context.Context, _ *agent.AgentSession) error { + return nil +} +func (a *pathSensitiveTestAgent) FormatResumeCommand(_ string) string { return "" } + +func createLinkedWorktreeForTranscriptTest(t *testing.T, worktreeDirFn func(mainRepo string) string) string { + t.Helper() + + mainRepo := t.TempDir() + resolved, err := filepath.EvalSymlinks(mainRepo) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + mainRepo = resolved + + testutil.InitRepo(t, mainRepo) + testutil.WriteFile(t, mainRepo, "f.txt", "init") + testutil.GitAdd(t, mainRepo, "f.txt") + testutil.GitCommit(t, mainRepo, "init") + worktreeDir := filepath.Clean(worktreeDirFn(mainRepo)) + + cmd := exec.Command("git", "worktree", "add", "-b", "feature-branch", worktreeDir) //nolint:noctx // test code + cmd.Dir = mainRepo + cmd.Env = testutil.GitIsolatedEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git worktree add: %v\n%s", err, output) + } + + paths.ClearWorktreeRootCache() + t.Chdir(worktreeDir) + + return worktreeDir +} + func TestAgentTranscriptPath(t *testing.T) { tests := []struct { name string @@ -47,6 +122,105 @@ func TestAgentTranscriptPath(t *testing.T) { } } +func TestResolveTranscriptPath_ClaudeNestedWorktreeUsesMainRepoRoot(t *testing.T) { + worktreeDir := createLinkedWorktreeForTranscriptTest(t, func(mainRepo string) string { + return filepath.Join(mainRepo, ".claude", "worktrees", "feature-branch") + }) + mainRepo, err := paths.MainRepoRoot(context.Background()) + if err != nil { + t.Fatalf("MainRepoRoot() error = %v", err) + } + + ag := &claudecode.ClaudeCodeAgent{} + got, err := resolveTranscriptPath(context.Background(), "session-123", ag) + if err != nil { + t.Fatalf("resolveTranscriptPath() error = %v", err) + } + + sessionDir, err := ag.GetSessionDir(mainRepo) + if err != nil { + t.Fatalf("GetSessionDir(mainRepo) error = %v", err) + } + want := ag.ResolveSessionFile(sessionDir, "session-123") + if got != want { + t.Fatalf("resolveTranscriptPath() = %q, want %q", got, want) + } + + // Sanity check: the nested worktree would resolve differently if we keyed off + // the worktree path, which is exactly the case we want to avoid here. + worktreeSessionDir, err := ag.GetSessionDir(worktreeDir) + if err != nil { + t.Fatalf("GetSessionDir(worktreeDir) error = %v", err) + } + if want == ag.ResolveSessionFile(worktreeSessionDir, "session-123") { + t.Fatal("expected nested worktree path to differ from main repo path for Claude") + } +} + +func TestResolveTranscriptPath_ClaudeRegularLinkedWorktreeUsesWorktreeRoot(t *testing.T) { + worktreeBase := t.TempDir() + _ = createLinkedWorktreeForTranscriptTest(t, func(_ string) string { + return filepath.Join(worktreeBase, "cli_worktree1") + }) + mainRepo, err := paths.MainRepoRoot(context.Background()) + if err != nil { + t.Fatalf("MainRepoRoot() error = %v", err) + } + worktreeDir, err := paths.WorktreeRoot(context.Background()) + if err != nil { + t.Fatalf("WorktreeRoot() error = %v", err) + } + + ag := &claudecode.ClaudeCodeAgent{} + got, err := resolveTranscriptPath(context.Background(), "session-123", ag) + if err != nil { + t.Fatalf("resolveTranscriptPath() error = %v", err) + } + + sessionDir, err := ag.GetSessionDir(worktreeDir) + if err != nil { + t.Fatalf("GetSessionDir(worktreeDir) error = %v", err) + } + want := ag.ResolveSessionFile(sessionDir, "session-123") + if got != want { + t.Fatalf("resolveTranscriptPath() = %q, want %q", got, want) + } + + mainSessionDir, err := ag.GetSessionDir(mainRepo) + if err != nil { + t.Fatalf("GetSessionDir(mainRepo) error = %v", err) + } + if want == ag.ResolveSessionFile(mainSessionDir, "session-123") { + t.Fatal("expected regular linked worktree path to differ from main repo path for Claude") + } +} + +func TestResolveTranscriptPath_PathSensitiveAgentLinkedWorktreeUsesWorktreeRoot(t *testing.T) { + worktreeBase := t.TempDir() + _ = createLinkedWorktreeForTranscriptTest(t, func(_ string) string { + return filepath.Join(worktreeBase, "agent_worktree") + }) + worktreeDir, err := paths.WorktreeRoot(context.Background()) + if err != nil { + t.Fatalf("WorktreeRoot() error = %v", err) + } + + ag := &pathSensitiveTestAgent{} + got, err := resolveTranscriptPath(context.Background(), "session-123", ag) + if err != nil { + t.Fatalf("resolveTranscriptPath() error = %v", err) + } + + sessionDir, err := ag.GetSessionDir(worktreeDir) + if err != nil { + t.Fatalf("GetSessionDir(worktreeDir) error = %v", err) + } + want := ag.ResolveSessionFile(sessionDir, "session-123") + if got != want { + t.Fatalf("resolveTranscriptPath() = %q, want %q", got, want) + } +} + func TestFindCheckpointUUID(t *testing.T) { transcript := []transcriptLine{ {Type: "user", UUID: "u1", Message: []byte(`{"content":"First prompt"}`)},