diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index d0f5b99ee7..b2bc4c708e 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -7,11 +7,13 @@ import ( "io" "log/slog" "os" + "path/filepath" "strings" "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/validation" ) // Compile-time interface assertions for new interfaces. @@ -104,7 +106,7 @@ func (c *ClaudeCodeAgent) parseSessionStart(stdin io.Reader) (*agent.Event, erro return &agent.Event{ Type: agent.SessionStart, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), Model: raw.Model, Timestamp: time.Now(), }, nil @@ -118,7 +120,7 @@ func (c *ClaudeCodeAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) return &agent.Event{ Type: agent.TurnStart, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), Prompt: raw.Prompt, Timestamp: time.Now(), }, nil @@ -132,7 +134,7 @@ func (c *ClaudeCodeAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { return &agent.Event{ Type: agent.TurnEnd, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), Model: raw.Model, Timestamp: time.Now(), }, nil @@ -146,7 +148,7 @@ func (c *ClaudeCodeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) return &agent.Event{ Type: agent.SessionEnd, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), Model: raw.Model, Timestamp: time.Now(), }, nil @@ -160,7 +162,7 @@ func (c *ClaudeCodeAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, err return &agent.Event{ Type: agent.SubagentStart, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), ToolUseID: raw.ToolUseID, ToolInput: raw.ToolInput, Timestamp: time.Now(), @@ -175,7 +177,7 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error event := &agent.Event{ Type: agent.SubagentEnd, SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, + SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID), ToolUseID: raw.ToolUseID, ToolInput: raw.ToolInput, Timestamp: time.Now(), @@ -186,6 +188,77 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error return event, nil } +// claudeWorktreeMarker appears in an encoded project-dir segment when Claude +// Code is invoked from inside its worktree feature: SanitizePathForClaude maps +// every non-alphanumeric character to '-', so a CWD ending in +// "/.claude/worktrees/" produces a segment containing +// "--claude-worktrees-". +const claudeWorktreeMarker = "--claude-worktrees-" + +// resolveTranscriptPath recovers from a Claude Code bug where transcript_path +// encodes the worktree CWD ("...--claude-worktrees-") but the file is +// actually written under the parent-repo project dir. Returns sessionRef +// unchanged on the fast path; only the IsNotExist branch consults the +// filesystem a second time. Tight gates (sessionID validation, base-prefix +// check, marker presence, candidate must exist) keep the fallback from +// crossing into an unrelated project that happens to share a session ID. +func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) string { + if sessionRef == "" || sessionID == "" { + return sessionRef + } + if _, err := os.Stat(sessionRef); err == nil { + return sessionRef + } else if !os.IsNotExist(err) { + return sessionRef + } + if err := validation.ValidateAgentSessionID(sessionID); err != nil { + return sessionRef + } + base, err := c.GetSessionBaseDir() + if err != nil { + return sessionRef + } + candidate := worktreeParentCandidate(filepath.Clean(base), filepath.Clean(sessionRef), sessionID) + if candidate == "" { + return sessionRef + } + if _, err := os.Stat(candidate); err != nil { + return sessionRef + } + logging.Info(logging.WithComponent(context.Background(), "agent.claudecode"), + "resolved transcript via worktree fallback", + slog.String("reported", sessionRef), + slog.String("found", candidate), + slog.String("session_id", sessionID), + ) + return candidate +} + +// worktreeParentCandidate returns the parent-repo equivalent of reported when +// the project segment carries the Claude Code worktree marker. Returns "" if +// the input doesn't qualify. Callers must validate sessionID before calling. +func worktreeParentCandidate(base, reported, sessionID string) string { + sep := string(os.PathSeparator) + prefix := base + sep + if !strings.HasPrefix(reported, prefix) { + return "" + } + projectSeg, _, ok := strings.Cut(reported[len(prefix):], sep) + if !ok || projectSeg == "" { + return "" + } + // LastIndex (not Index): the synthetic marker is always the trailing + // occurrence — Claude appends "/.claude/worktrees/" to the cwd + // and SanitizePathForClaude preserves order. Cutting at the first match + // would mis-strip repos already checked out under a path literally + // containing "--claude-worktrees-". + idx := strings.LastIndex(projectSeg, claudeWorktreeMarker) + if idx <= 0 { + return "" + } + return filepath.Join(base, projectSeg[:idx], sessionID+".jsonl") +} + // --- Transcript flush sentinel --- // stopHookSentinel is the string that appears in Claude Code's hook_progress diff --git a/cmd/entire/cli/agent/claudecode/lifecycle_test.go b/cmd/entire/cli/agent/claudecode/lifecycle_test.go index 4e95f866dd..b4506fcd1c 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle_test.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle_test.go @@ -542,3 +542,193 @@ func TestWaitForTranscriptFlush_NonexistentFile_ReturnsImmediately(t *testing.T) t.Errorf("expected immediate return for nonexistent file, but took %v", elapsed) } } + +const testClaudeProjectsBase = "/home/u/.claude/projects" + +func TestWorktreeParentCandidate(t *testing.T) { + t.Parallel() + + reported := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo--claude-worktrees-feature", "sess-1.jsonl") + got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1") + want := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo", "sess-1.jsonl") + if got != want { + t.Errorf("worktreeParentCandidate = %q, want %q", got, want) + } +} + +func TestWorktreeParentCandidate_NoMarker(t *testing.T) { + t.Parallel() + + reported := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo", "sess-1.jsonl") + if got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1"); got != "" { + t.Errorf("expected empty (no marker), got %q", got) + } +} + +func TestWorktreeParentCandidate_OutsideBase(t *testing.T) { + t.Parallel() + + if got := worktreeParentCandidate("/a/base", "/somewhere/else/sess-1.jsonl", "sess-1"); got != "" { + t.Errorf("expected empty (outside base), got %q", got) + } +} + +// TestWorktreeParentCandidate_MarkerInRepoRoot covers a repo whose sanitized +// root already contains the literal "--claude-worktrees-" token (e.g. checked +// out under a directory literally named "acme--claude-worktrees-tools"). Only +// the trailing, synthetic occurrence — the suffix Claude appends from +// .claude/worktrees/ — should be stripped. Cutting at the first +// occurrence would point at the wrong project dir and re-introduce the +// dropped-checkpoint bug for that class of repos. +func TestWorktreeParentCandidate_MarkerInRepoRoot(t *testing.T) { + t.Parallel() + + parent := "-Users-me-acme--claude-worktrees-tools-repo" + worktree := parent + "--claude-worktrees-feature" + reported := filepath.Join(testClaudeProjectsBase, worktree, "sess-1.jsonl") + got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1") + want := filepath.Join(testClaudeProjectsBase, parent, "sess-1.jsonl") + if got != want { + t.Errorf("worktreeParentCandidate = %q, want %q (must strip only the trailing synthetic marker)", got, want) + } +} + +func TestWorktreeParentCandidate_MarkerAtStart(t *testing.T) { + t.Parallel() + + // Marker at index 0 of the project segment is meaningless — there is no + // parent path to recover. Helper returns "". + reported := filepath.Join(testClaudeProjectsBase, "--claude-worktrees-feature", "sess-1.jsonl") + if got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1"); got != "" { + t.Errorf("expected empty (marker at start), got %q", got) + } +} + +func TestResolveTranscriptPath_PassthroughWhenExists(t *testing.T) { + t.Parallel() + + transcript := filepath.Join(t.TempDir(), "real.jsonl") + if err := os.WriteFile(transcript, []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + ag := &ClaudeCodeAgent{} + got := ag.resolveTranscriptPath(transcript, "any-session-id") + if got != transcript { + t.Errorf("resolveTranscriptPath returned %q, want passthrough %q", got, transcript) + } +} + +func TestResolveTranscriptPath_EmptyInputs(t *testing.T) { + t.Parallel() + + ag := &ClaudeCodeAgent{} + if got := ag.resolveTranscriptPath("", "id"); got != "" { + t.Errorf("empty sessionRef should pass through; got %q", got) + } + if got := ag.resolveTranscriptPath("/some/path", ""); got != "/some/path" { + t.Errorf("empty sessionID should pass through; got %q", got) + } +} + +// TestResolveTranscriptPath_WorktreeFallback simulates the Claude Code worktree +// bug: the agent reports a transcript_path under a "--claude-worktrees-" +// project dir that doesn't exist, while the actual transcript was written under +// the parent repo's project dir. The resolver should find the real file by +// scanning the projects base dir for the session ID. +func TestResolveTranscriptPath_WorktreeFallback(t *testing.T) { + // Cannot t.Parallel() — uses t.Setenv on HOME (process-global). + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + base := filepath.Join(tmpHome, ".claude", "projects") + parentDir := filepath.Join(base, "-Users-foo-Development-repo") + if err := os.MkdirAll(parentDir, 0o755); err != nil { + t.Fatalf("mkdir parent project: %v", err) + } + sessionID := "wt-session-uuid" + realPath := filepath.Join(parentDir, sessionID+".jsonl") + if err := os.WriteFile(realPath, []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write transcript: %v", err) + } + + // Reported path encodes the worktree CWD — does not exist on disk. + reported := filepath.Join(base, "-Users-foo-Development-repo--claude-worktrees-feature", sessionID+".jsonl") + + ag := &ClaudeCodeAgent{} + got := ag.resolveTranscriptPath(reported, sessionID) + if got != realPath { + t.Errorf("resolveTranscriptPath = %q, want %q (real location)", got, realPath) + } +} + +// TestResolveTranscriptPath_NoMarker_Passthrough verifies that when the +// reported path is under the projects base but the project segment does not +// carry the worktree marker, the resolver returns the original path unchanged +// rather than fabricating a candidate. +func TestResolveTranscriptPath_NoMarker_Passthrough(t *testing.T) { + // Cannot t.Parallel() — uses t.Setenv on HOME. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + base := filepath.Join(tmpHome, ".claude", "projects") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + reported := filepath.Join(base, "-Users-foo-Development-repo", "missing.jsonl") + + ag := &ClaudeCodeAgent{} + got := ag.resolveTranscriptPath(reported, "any-id") + if got != reported { + t.Errorf("resolveTranscriptPath = %q, want passthrough %q", got, reported) + } +} + +// TestResolveTranscriptPath_OutsideBaseDir verifies the resolver does not scan +// when the reported path is outside the Claude projects base dir — scanning +// elsewhere couldn't produce a more correct answer and just adds I/O. +func TestResolveTranscriptPath_OutsideBaseDir(t *testing.T) { + // Cannot t.Parallel() — uses t.Setenv on HOME. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + // A real transcript exists under the projects base, but the reported path + // is somewhere else entirely. Resolver should not redirect. + base := filepath.Join(tmpHome, ".claude", "projects") + dir := filepath.Join(base, "some-project") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + sessionID := "outside-session" + if err := os.WriteFile(filepath.Join(dir, sessionID+".jsonl"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write transcript: %v", err) + } + + reported := filepath.Join(t.TempDir(), "elsewhere", sessionID+".jsonl") // not under base + ag := &ClaudeCodeAgent{} + got := ag.resolveTranscriptPath(reported, sessionID) + if got != reported { + t.Errorf("resolveTranscriptPath = %q, want passthrough %q (path outside base)", got, reported) + } +} + +// TestResolveTranscriptPath_RejectsTraversalSessionID verifies that a session +// ID containing path separators is rejected before being used in filepath.Join. +func TestResolveTranscriptPath_RejectsTraversalSessionID(t *testing.T) { + // Cannot t.Parallel() — uses t.Setenv on HOME. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + base := filepath.Join(tmpHome, ".claude", "projects", "proj") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // A file with a traversal-shaped name exists; the resolver must NOT match it. + traversalID := "../../etc/passwd" + reported := filepath.Join(tmpHome, ".claude", "projects", "proj", "missing.jsonl") + + ag := &ClaudeCodeAgent{} + got := ag.resolveTranscriptPath(reported, traversalID) + if got != reported { + t.Errorf("resolveTranscriptPath = %q, want passthrough %q (traversal id rejected)", got, reported) + } +} diff --git a/cmd/entire/cli/integration_test/claude_worktree_path_test.go b/cmd/entire/cli/integration_test/claude_worktree_path_test.go new file mode 100644 index 0000000000..bc10c1e082 --- /dev/null +++ b/cmd/entire/cli/integration_test/claude_worktree_path_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package integration + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// TestClaudeCode_WorktreePathFallback exercises the lifecycle handler through +// the Stop hook when Claude Code reports a transcript_path under a +// "--claude-worktrees-" project directory that does not exist on disk. +// The resolver in resolveTranscriptPath should redirect to the parent repo's +// project directory (where the file actually lives) so checkpoint creation +// proceeds. Regression test for the dropped-checkpoint bug seen when Claude +// is invoked from inside its own .claude/worktrees feature. +func TestClaudeCode_WorktreePathFallback(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t) + + // Fake HOME so claudecode.GetSessionBaseDir() resolves under our control — + // it deliberately bypasses ENTIRE_TEST_CLAUDE_PROJECT_DIR. + fakeHome := t.TempDir() + + parentSegment := claudecode.SanitizePathForClaude(env.RepoDir) + realDir := filepath.Join(fakeHome, ".claude", "projects", parentSegment) + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("mkdir parent project: %v", err) + } + + session := env.NewSession() + session.TranscriptPath = filepath.Join(realDir, session.ID+".jsonl") + + // Reported path encodes the worktree CWD. Must not exist. + reportedPath := filepath.Join( + fakeHome, ".claude", "projects", + parentSegment+"--claude-worktrees-feature", + session.ID+".jsonl", + ) + if _, err := os.Stat(reportedPath); !os.IsNotExist(err) { + t.Fatalf("reported path should not exist on disk; stat err=%v", err) + } + + extraEnv := []string{ + "HOME=" + fakeHome, + "ENTIRE_TEST_CLAUDE_PROJECT_DIR=" + env.ClaudeProjectDir, + } + + runHook := func(hookName string, payload map[string]string) { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal %s: %v", hookName, err) + } + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", hookName) + cmd.Dir = env.RepoDir + cmd.Stdin = bytes.NewReader(body) + cmd.Env = append(testutil.GitIsolatedEnv(), extraEnv...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("hook %s failed: %v\nOutput: %s", hookName, err, out) + } + } + + // Order matches a real session: prompt-submit captures pre-state, then the + // agent does work (writes a file + transcript), then Stop fires. + runHook("user-prompt-submit", map[string]string{ + "session_id": session.ID, + "transcript_path": reportedPath, + }) + + env.WriteFile("worktree_file.txt", "from claude") + realPath := session.CreateTranscript("Add a worktree file", []FileChange{ + {Path: "worktree_file.txt", Content: "from claude"}, + }) + + // Backdate mtime so waitForTranscriptFlush treats the file as stale and + // skips its 3s sentinel poll. Freshness logic is incidental to the + // regression under test — keep the test fast. + stale := time.Now().Add(-10 * time.Minute) + if err := os.Chtimes(realPath, stale, stale); err != nil { + t.Fatalf("chtimes: %v", err) + } + + runHook("stop", map[string]string{ + "session_id": session.ID, + "transcript_path": reportedPath, + }) + + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point — resolver failed to redirect to the parent-encoded transcript") + } +} diff --git a/cmd/entire/cli/integration_test/linked_worktree_setup_test.go b/cmd/entire/cli/integration_test/linked_worktree_setup_test.go new file mode 100644 index 0000000000..98296e43c0 --- /dev/null +++ b/cmd/entire/cli/integration_test/linked_worktree_setup_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package integration + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" +) + +// TestLinkedWorktree_EntireSetUpVisible verifies that when Entire is enabled +// in a repo and the user (or an agent like Claude Code's worktrees feature) +// creates a linked worktree under .claude/worktrees/, running +// `entire status` inside that linked worktree treats it as set up — i.e. +// resolves .entire/settings.json against the main worktree root, not the +// linked worktree root. +// +// Regression: prior to anchoring .entire/* paths at MainWorktreeRoot in +// paths.AbsPath, every git hook fired from inside a linked worktree (e.g. +// prepare-commit-msg, post-commit, pre-push) saw IsSetUpAndEnabled = false +// and silently bailed out. The user-visible symptom was that commits made in +// agent worktrees never received the Entire-Checkpoint trailer, were never +// condensed, and never pushed `entire/checkpoints/v1`. Status was the +// shortest path to a regression test for that gating decision. +func TestLinkedWorktree_EntireSetUpVisible(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t) + + // Sanity check: from the main worktree, status reports a healthy repo. + mainStatus := env.RunCLI("status") + if strings.Contains(mainStatus, "not set up") { + t.Fatalf("baseline status from main worktree shows not-set-up; setup is wrong:\n%s", mainStatus) + } + + // Create a linked worktree shaped like Claude Code's worktrees feature. + // `git worktree add -b ` requires the parent dir to exist. + worktreeDir := filepath.Join(env.RepoDir, ".claude", "worktrees", "feature-x") + runGitIn(t, env.RepoDir, "worktree", "add", "-b", "feature-x", worktreeDir) + + // Run `entire status` inside the linked worktree. This is the integration + // surface that proves AbsPath now anchors .entire/* paths at the main + // repo: IsSetUp walks paths.AbsPath -> MainWorktreeRoot rather than + // show-toplevel of the linked worktree. + out, err := runCLIIn(env, worktreeDir, "status") + if err != nil { + t.Fatalf("entire status inside linked worktree failed: %v\n%s", err, out) + } + if strings.Contains(out, "not set up") { + t.Errorf("entire status from linked worktree reports not-set-up; .entire/* path resolution is not anchored at main worktree.\nOutput:\n%s", out) + } +} + +func runGitIn(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } +} + +func runCLIIn(env *TestEnv, dir string, args ...string) (string, error) { + cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...) + cmd.Dir = dir + cmd.Env = env.cliEnv() + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/cmd/entire/cli/logging/logger.go b/cmd/entire/cli/logging/logger.go index 05e01e1cb4..ec0572742d 100644 --- a/cmd/entire/cli/logging/logger.go +++ b/cmd/entire/cli/logging/logger.go @@ -110,11 +110,19 @@ func Init(ctx context.Context, sessionID string) error { fmt.Fprintf(os.Stderr, "[entire] Warning: invalid log level %q, defaulting to INFO\n", levelStr) } - // Determine log file path - repoRoot, err := paths.WorktreeRoot(ctx) + // Determine log file path. Anchor at the main worktree root so that hooks + // firing from inside a linked worktree (e.g. Claude Code's agent-managed + // .claude/worktrees/) write to the canonical .entire/logs/entire.log + // rather than creating an orphan logs directory inside the linked worktree. + repoRoot, err := paths.MainWorktreeRoot(ctx) if err != nil { - // Fall back to current directory - repoRoot = "." + // Fall back to the current worktree, then cwd, so logging still works + // in environments where --git-common-dir resolution fails. + if alt, altErr := paths.WorktreeRoot(ctx); altErr == nil { + repoRoot = alt + } else { + repoRoot = "." + } } logsPath := filepath.Join(repoRoot, LogsDir) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 039c6fadae..95fcc9a673 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -2,6 +2,7 @@ package paths import ( "context" + "errors" "fmt" "os" "os/exec" @@ -77,6 +78,10 @@ var ( worktreeRootMu sync.RWMutex worktreeRootCache string worktreeRootCacheDir string + + mainWorktreeRootMu sync.RWMutex + mainWorktreeRootCache string + mainWorktreeRootCacheDir string ) // WorktreeRoot returns the git worktree root directory. @@ -124,16 +129,89 @@ func ClearWorktreeRootCache() { worktreeRootCache = "" worktreeRootCacheDir = "" worktreeRootMu.Unlock() + + mainWorktreeRootMu.Lock() + mainWorktreeRootCache = "" + mainWorktreeRootCacheDir = "" + mainWorktreeRootMu.Unlock() +} + +// MainWorktreeRoot returns the directory containing the main (non-linked) +// worktree's .git directory. When invoked from a linked worktree (including +// agent-managed worktrees such as Claude Code's .claude/worktrees/), +// this returns the main repo root, not the linked worktree root. +// +// Resolution uses 'git rev-parse --path-format=absolute --git-common-dir' +// (whose parent directory is the main worktree). This is the canonical anchor +// for .entire/ state: settings, metadata, logs all live in the main repo and +// must be looked up there regardless of which linked worktree the caller is in. +// +// The result is cached per working directory. Returns an error if not inside +// a git repository. +func MainWorktreeRoot(ctx context.Context) (string, error) { + cwd, err := os.Getwd() //nolint:forbidigo // mirrors WorktreeRoot's pattern + if err != nil { + cwd = "" + } + + mainWorktreeRootMu.RLock() + if mainWorktreeRootCache != "" && mainWorktreeRootCacheDir == cwd { + cached := mainWorktreeRootCache + mainWorktreeRootMu.RUnlock() + return cached, nil + } + mainWorktreeRootMu.RUnlock() + + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--path-format=absolute", "--git-common-dir") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get git common dir: %w", err) + } + + commonDir := strings.TrimSpace(string(output)) + if commonDir == "" { + return "", errors.New("git rev-parse --git-common-dir returned empty output") + } + + // The main worktree root is the parent directory of the common .git dir. + // (Bare repos have no worktree; entire requires a worktree, so we don't + // special-case that here — the caller will fail later if there's no tree.) + root := filepath.Dir(filepath.Clean(commonDir)) + + mainWorktreeRootMu.Lock() + mainWorktreeRootCache = root + mainWorktreeRootCacheDir = cwd + mainWorktreeRootMu.Unlock() + + return root, 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. +// +// Paths under .entire/ (settings, metadata, logs, tmp) are anchored at the +// main worktree root so that linked worktrees — including agent-managed +// worktrees like Claude Code's .claude/worktrees/ — share a single +// canonical configuration and state directory with the main repo. Without +// this, hooks firing from inside a linked worktree fail to find settings, +// see Entire as "not set up", and silently skip work (no trailer added to +// the commit, no condensation, no push of entire/checkpoints/v1). +// +// All other relative paths resolve against the current worktree root via +// WorktreeRoot(). func AbsPath(ctx context.Context, relPath string) (string, error) { if filepath.IsAbs(relPath) { return relPath, nil } + if IsSubpath(EntireDir, relPath) { + root, err := MainWorktreeRoot(ctx) + if err != nil { + return "", err + } + return filepath.Join(root, relPath), nil + } + root, err := WorktreeRoot(ctx) if err != nil { return "", err diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index b442c660a4..eeab062d40 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -1,7 +1,9 @@ package paths import ( + "context" "os" + "os/exec" "path/filepath" "runtime" "testing" @@ -198,3 +200,168 @@ func TestNormalizeMSYSPath(t *testing.T) { }) } } + +// TestAbsPath_EntirePrefixDetection locks down the input shapes that AbsPath +// must route through MainWorktreeRoot rather than WorktreeRoot. Look-alikes +// (".entirefile") and parent-escapes (".entire/../etc/passwd") must NOT match — +// they would otherwise be silently rebased onto the main repo even though +// they target a different location. +func TestAbsPath_EntirePrefixDetection(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want bool + }{ + {name: "bare entire dir", in: ".entire", want: true}, + {name: "settings file", in: ".entire/settings.json", want: true}, + {name: "nested metadata", in: ".entire/metadata/abc/full.jsonl", want: true}, + {name: "tmp dir", in: ".entire/tmp", want: true}, + {name: "messy slashes", in: ".entire//metadata/x", want: true}, + {name: "look-alike prefix", in: ".entirefile", want: false}, + {name: "parent escape", in: ".entire/../etc/passwd", want: false}, + {name: "non-entire", in: "src/main.go", want: false}, + {name: "absolute treated as non-match", in: "/foo/.entire/settings.json", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsSubpath(EntireDir, tt.in); got != tt.want { + t.Errorf("IsSubpath(%q, %q) = %v, want %v", EntireDir, tt.in, got, tt.want) + } + }) + } +} + +// TestMainWorktreeRoot_LinkedWorktree verifies that when called from inside +// a linked worktree (e.g. Claude Code's agent-managed worktrees under +// .claude/worktrees/), MainWorktreeRoot returns the *main* repo's +// root, not the linked worktree root. Regression coverage for the case where +// git hooks fired from a linked worktree could not find .entire/settings.json +// and silently bailed. +func TestMainWorktreeRoot_LinkedWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + mainRoot := initSeedRepo(t) + worktreeDir := filepath.Join(mainRoot, ".claude", "worktrees", "feature-x") + runGit(t, mainRoot, "worktree", "add", "-b", "feature-x", worktreeDir) + + t.Chdir(worktreeDir) + ClearWorktreeRootCache() + + got, err := MainWorktreeRoot(context.Background()) + if err != nil { + t.Fatalf("MainWorktreeRoot from linked worktree: %v", err) + } + + if mustEvalSymlinks(t, got) != mustEvalSymlinks(t, mainRoot) { + t.Errorf("MainWorktreeRoot = %q, want main repo %q", got, mainRoot) + } + + // Sanity: ordinary WorktreeRoot from the same cwd points at the linked + // worktree, not the main repo. This is what makes the bug possible and + // why MainWorktreeRoot has to exist as a separate anchor. + wtRoot, err := WorktreeRoot(context.Background()) + if err != nil { + t.Fatalf("WorktreeRoot: %v", err) + } + if mustEvalSymlinks(t, wtRoot) == mustEvalSymlinks(t, mainRoot) { + t.Errorf("WorktreeRoot from linked worktree (%q) should differ from main (%q); test setup may be wrong", wtRoot, mainRoot) + } +} + +// TestAbsPath_EntireAnchoredAtMainWorktree verifies that AbsPath resolves +// .entire/* paths against the main worktree root even when called from inside +// a linked worktree, while non-entire paths still resolve against the current +// worktree. This is the load-bearing assertion for the hook fix: without it, +// a hook firing from a Claude-managed worktree would fail the IsSetUp check +// (.entire/ does not exist in the worktree) and silently bail. +func TestAbsPath_EntireAnchoredAtMainWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + mainRoot := initSeedRepo(t) + worktreeDir := filepath.Join(mainRoot, ".claude", "worktrees", "feature-x") + runGit(t, mainRoot, "worktree", "add", "-b", "feature-x", worktreeDir) + + t.Chdir(worktreeDir) + ClearWorktreeRootCache() + + ctx := context.Background() + + // Pre-create the targets so we can compare via EvalSymlinks (TempDir on + // macOS lives under /private/var/... but is reported as /var/...). + if err := os.MkdirAll(filepath.Join(mainRoot, ".entire"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.MkdirAll(filepath.Join(worktreeDir, "src"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + entireAbs, err := AbsPath(ctx, ".entire/settings.json") + if err != nil { + t.Fatalf("AbsPath(.entire/settings.json): %v", err) + } + wantEntireDir := mustEvalSymlinks(t, filepath.Join(mainRoot, ".entire")) + if got := mustEvalSymlinks(t, filepath.Dir(entireAbs)); got != wantEntireDir { + t.Errorf("AbsPath(.entire/settings.json) parent = %q, want %q", got, wantEntireDir) + } + + // Non-entire relative paths must still anchor at the current worktree. + srcAbs, err := AbsPath(ctx, "src/main.go") + if err != nil { + t.Fatalf("AbsPath(src/main.go): %v", err) + } + wantSrcDir := mustEvalSymlinks(t, filepath.Join(worktreeDir, "src")) + if got := mustEvalSymlinks(t, filepath.Dir(srcAbs)); got != wantSrcDir { + t.Errorf("AbsPath(src/main.go) parent = %q, want %q", got, wantSrcDir) + } + + // Absolute inputs are returned unchanged regardless of prefix. + absIn := filepath.Join(mainRoot, ".entire", "x") + absOut, err := AbsPath(ctx, absIn) + if err != nil { + t.Fatalf("AbsPath(absolute): %v", err) + } + if absOut != absIn { + t.Errorf("AbsPath(%q) = %q, want unchanged", absIn, absOut) + } +} + +func initSeedRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main", ".") + runGit(t, root, "config", "user.email", "test@example.com") + runGit(t, root, "config", "user.name", "Test") + runGit(t, root, "config", "commit.gpgsign", "false") + // At least one commit is required before `git worktree add -b` will succeed. + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runGit(t, root, "add", "README.md") + runGit(t, root, "commit", "-q", "-m", "seed") + return root +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } +} + +func mustEvalSymlinks(t *testing.T, p string) string { + t.Helper() + r, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", p, err) + } + return r +} diff --git a/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go new file mode 100644 index 0000000000..dbd405b524 --- /dev/null +++ b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go @@ -0,0 +1,267 @@ +package strategy + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/session" +) + +// TestFindSessionsForWorktree_LinkedWorktreeFallsBackToMain verifies that a +// hook firing from inside a linked worktree (such as Claude Code's +// .claude/worktrees/ agent feature) falls back to sessions whose +// WorktreePath was registered against the main worktree when no session is +// registered against the linked worktree itself. +// +// Regression coverage for the path where prepare-commit-msg / post-commit +// silently bail with "no active sessions" — strict path equality on +// WorktreePath was returning no matches because the session was created from +// the main worktree (where the user's prompt and UserPromptSubmit hook fired) +// but the commit fires from inside an agent-spawned linked worktree. +func TestFindSessionsForWorktree_LinkedWorktreeFallsBackToMain(t *testing.T) { + mainDir := t.TempDir() + // EvalSymlinks so /var → /private/var on macOS — git rev-parse returns the + // canonical form, and we need to compare like-for-like in the test below. + resolved, err := filepath.EvalSymlinks(mainDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + mainDir = resolved + initTestRepo(t, mainDir) + + linkedDir := filepath.Join(mainDir, ".claude", "worktrees", "feature-x") + if err := createWorktree(mainDir, linkedDir, "feature-x"); err != nil { + t.Fatalf("createWorktree: %v", err) + } + t.Cleanup(func() { removeWorktree(mainDir, linkedDir) }) + + t.Chdir(linkedDir) + + strat := NewManualCommitStrategy() + ctx := context.Background() + + // Active session registered against the MAIN worktree path. Phase=Active so + // listAllSessionStates doesn't prune it for lacking a shadow branch. + mainSession := &session.State{ + SessionID: "11111111-1111-1111-1111-111111111111", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: mainDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, mainSession); err != nil { + t.Fatalf("saveSessionState (main): %v", err) + } + + // Session registered against an unrelated sibling worktree — must NOT + // match a commit firing from linkedDir. + siblingDir := filepath.Join(mainDir, ".claude", "worktrees", "sibling") + otherSession := &session.State{ + SessionID: "22222222-2222-2222-2222-222222222222", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: siblingDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, otherSession); err != nil { + t.Fatalf("saveSessionState (sibling): %v", err) + } + + matched, err := strat.findSessionsForWorktree(ctx, linkedDir) + if err != nil { + t.Fatalf("findSessionsForWorktree: %v", err) + } + + gotIDs := map[string]bool{} + for _, s := range matched { + gotIDs[s.SessionID] = true + } + if !gotIDs[mainSession.SessionID] { + t.Errorf("expected main-worktree session %q to be returned from linked worktree, got %+v", mainSession.SessionID, gotIDs) + } + if gotIDs[otherSession.SessionID] { + t.Errorf("sibling-worktree session %q should NOT match from linked worktree %q", otherSession.SessionID, linkedDir) + } +} + +// TestFindSessionsForWorktree_MainWorktreeUnchanged verifies the widening is +// strictly one-directional: from the main worktree we must not start +// returning sessions whose WorktreePath points at a linked worktree. +// Otherwise a commit on main would silently pick up an unrelated agent +// session running in .claude/worktrees/ and link the commit to it. +func TestFindSessionsForWorktree_MainWorktreeUnchanged(t *testing.T) { + mainDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(mainDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + mainDir = resolved + initTestRepo(t, mainDir) + t.Chdir(mainDir) + + strat := NewManualCommitStrategy() + ctx := context.Background() + + mainSession := &session.State{ + SessionID: "33333333-3333-3333-3333-333333333333", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: mainDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, mainSession); err != nil { + t.Fatalf("saveSessionState (main): %v", err) + } + + linkedDir := filepath.Join(mainDir, ".claude", "worktrees", "feature-x") + linkedSession := &session.State{ + SessionID: "44444444-4444-4444-4444-444444444444", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: linkedDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, linkedSession); err != nil { + t.Fatalf("saveSessionState (linked): %v", err) + } + + matched, err := strat.findSessionsForWorktree(ctx, mainDir) + if err != nil { + t.Fatalf("findSessionsForWorktree: %v", err) + } + + gotIDs := map[string]bool{} + for _, s := range matched { + gotIDs[s.SessionID] = true + } + if !gotIDs[mainSession.SessionID] { + t.Errorf("main-worktree session must be returned when looking up from main, got %+v", gotIDs) + } + if gotIDs[linkedSession.SessionID] { + t.Errorf("linked-worktree session %q must NOT bleed into main-worktree lookup, got %+v", linkedSession.SessionID, gotIDs) + } +} + +// TestFindSessionsForWorktree_LinkedWorktreeWithOwnSessionIgnoresMain verifies +// that the linked → main fallback only fires when the linked worktree has no +// session of its own. If the user has independently started an agent in a +// linked worktree (e.g. `git worktree add ../wt && cd ../wt && agent`), a +// commit there must link only to that worktree's session — bundling in main's +// session would silently attach unrelated session context to the commit. +func TestFindSessionsForWorktree_LinkedWorktreeWithOwnSessionIgnoresMain(t *testing.T) { + mainDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(mainDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + mainDir = resolved + initTestRepo(t, mainDir) + + linkedDir := filepath.Join(mainDir, ".claude", "worktrees", "feature-x") + if err := createWorktree(mainDir, linkedDir, "feature-x"); err != nil { + t.Fatalf("createWorktree: %v", err) + } + t.Cleanup(func() { removeWorktree(mainDir, linkedDir) }) + + t.Chdir(linkedDir) + + strat := NewManualCommitStrategy() + ctx := context.Background() + + // Session registered against the linked worktree — the user started an + // agent inside the worktree directly. + linkedSession := &session.State{ + SessionID: "55555555-5555-5555-5555-555555555555", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: linkedDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, linkedSession); err != nil { + t.Fatalf("saveSessionState (linked): %v", err) + } + + // Unrelated session also active in main. Must NOT be bundled with a + // commit happening in the linked worktree when the linked worktree has + // its own session. + mainSession := &session.State{ + SessionID: "66666666-6666-6666-6666-666666666666", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: mainDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, mainSession); err != nil { + t.Fatalf("saveSessionState (main): %v", err) + } + + matched, err := strat.findSessionsForWorktree(ctx, linkedDir) + if err != nil { + t.Fatalf("findSessionsForWorktree: %v", err) + } + + gotIDs := map[string]bool{} + for _, s := range matched { + gotIDs[s.SessionID] = true + } + if !gotIDs[linkedSession.SessionID] { + t.Errorf("linked-worktree session %q must be returned from its own worktree, got %+v", linkedSession.SessionID, gotIDs) + } + if gotIDs[mainSession.SessionID] { + t.Errorf("main-worktree session %q must NOT be bundled when the linked worktree has its own session, got %+v", mainSession.SessionID, gotIDs) + } +} + +// TestFindSessionsForWorktree_LinkedFallbackSkipsSiblings verifies that when a +// linked worktree falls back for lack of a local session, the fallback finds +// only the main-worktree session — never a sibling linked worktree's session. +// Without this, a commit in worktree A could silently adopt a session from a +// concurrent agent run in worktree B. +func TestFindSessionsForWorktree_LinkedFallbackSkipsSiblings(t *testing.T) { + mainDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(mainDir) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + mainDir = resolved + initTestRepo(t, mainDir) + + linkedDir := filepath.Join(mainDir, ".claude", "worktrees", "feature-x") + if err := createWorktree(mainDir, linkedDir, "feature-x"); err != nil { + t.Fatalf("createWorktree: %v", err) + } + t.Cleanup(func() { removeWorktree(mainDir, linkedDir) }) + + t.Chdir(linkedDir) + + strat := NewManualCommitStrategy() + ctx := context.Background() + + siblingDir := filepath.Join(mainDir, ".claude", "worktrees", "sibling") + siblingSession := &session.State{ + SessionID: "77777777-7777-7777-7777-777777777777", + BaseCommit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + WorktreePath: siblingDir, + StartedAt: time.Now(), + Phase: session.PhaseActive, + } + if err := strat.saveSessionState(ctx, siblingSession); err != nil { + t.Fatalf("saveSessionState (sibling): %v", err) + } + + // No session for linkedDir, no session for mainDir — fallback should + // resolve to an empty list, not to siblingSession. + matched, err := strat.findSessionsForWorktree(ctx, linkedDir) + if err != nil { + t.Fatalf("findSessionsForWorktree: %v", err) + } + if len(matched) != 0 { + var ids []string + for _, s := range matched { + ids = append(ids, s.SessionID) + } + t.Errorf("expected no matches (no local session, no main session); got %v", ids) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index f446529110..4d89cd3228 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -133,19 +133,60 @@ func countWarnableStaleEndedSessions(repo *git.Repository, sessions []*SessionSt } // findSessionsForWorktree finds all sessions for the given worktree path. +// +// When invoked from a linked worktree and no session is registered against +// that worktree, the lookup falls back to sessions registered against the +// main worktree. Agent-managed worktrees — like Claude Code's +// .claude/worktrees/ feature — spawn a sub-process that inherits a +// session whose UserPromptSubmit fired in the main worktree; the hook handler +// must still be able to find that session when prepare-commit-msg / +// post-commit / etc. run from inside the linked worktree at commit time. +// +// The fallback is one-directional and only kicks in when the linked worktree +// has no session of its own. This preserves three properties: +// +// 1. Linked-worktree sessions are never crowded out by main: if you ran +// `git worktree add ../wt && cd ../wt && agent`, your commits in `wt` +// link to the wt session, not also to some unrelated main session. +// 2. Sibling linked worktrees never bleed into each other: a commit in +// worktree A never picks up a session registered against worktree B. +// 3. Main-worktree lookups are unchanged: commits on main never adopt a +// session registered against a linked worktree. func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, worktreePath string) ([]*SessionState, error) { allStates, err := s.listAllSessionStates(ctx) if err != nil { return nil, err } - var matching []*SessionState + var localMatches []*SessionState for _, state := range allStates { if state.WorktreePath == worktreePath { - matching = append(matching, state) + localMatches = append(localMatches, state) } } - return matching, nil + if len(localMatches) > 0 { + return localMatches, nil + } + + // No local match. Fall back to main-worktree sessions only when we're + // actually in a different (linked) worktree. Failure to resolve the main + // path degrades gracefully to strict-equal — the caller is no worse off + // than before this fallback existed. + mainPath := "" + if p, mainErr := paths.MainWorktreeRoot(ctx); mainErr == nil { + mainPath = p + } + if mainPath == "" || mainPath == worktreePath { + return nil, nil + } + + var mainMatches []*SessionState + for _, state := range allStates { + if state.WorktreePath == mainPath { + mainMatches = append(mainMatches, state) + } + } + return mainMatches, nil } type rewritePair struct {