From 062641f28ee2a2219c6a88f86700e67a2b35f0c5 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Fri, 8 May 2026 16:52:41 +0200 Subject: [PATCH 1/7] Fall back to scanning when Claude transcript_path is missing Claude Code's worktree feature reports transcript_path encoded from the worktree CWD (.claude/worktrees/) but writes the actual transcript under the parent repo's project dir. The TurnEnd handler aborted on the file-exists check, so no checkpoint condensed and commits had no Entire-Checkpoint trailer. When the reported path doesn't exist, scan ~/.claude/projects/*/ for the session ID and use the match. No-op when the path resolves normally. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 710ca10c1821 --- cmd/entire/cli/agent/claudecode/lifecycle.go | 62 +++++++++-- .../cli/agent/claudecode/lifecycle_test.go | 102 ++++++++++++++++++ 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index d0f5b99ee7..0ce971ae4e 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "os" + "path/filepath" "strings" "time" @@ -104,7 +105,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 +119,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 +133,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 +147,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 +161,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 +176,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 +187,55 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error return event, nil } +// resolveTranscriptPath returns the canonical path to a Claude transcript file, +// recovering from the Claude Code worktree-feature mismatch where +// transcript_path encodes the worktree CWD (e.g. .claude/worktrees/) but +// the file is stored under the parent repo's project dir. Falls back to scanning +// ~/.claude/projects/*/.jsonl when the reported path doesn't exist. +func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) string { + if sessionRef == "" || sessionID == "" { + return sessionRef + } + if _, err := os.Stat(sessionRef); err == nil { + return sessionRef + } + base, err := c.GetSessionBaseDir() + if err != nil { + return sessionRef + } + found := findTranscriptByID(base, sessionID) + if found == "" { + return sessionRef + } + logging.Info(logging.WithComponent(context.Background(), "agent.claudecode"), + "resolved transcript via fallback scan", + slog.String("reported", sessionRef), + slog.String("found", found), + slog.String("session_id", sessionID), + ) + return found +} + +// findTranscriptByID scans baseDir's immediate child directories for a file +// named ".jsonl" and returns the first match, or "" if none. +func findTranscriptByID(baseDir, sessionID string) string { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "" + } + fname := sessionID + ".jsonl" + for _, e := range entries { + if !e.IsDir() { + continue + } + candidate := filepath.Join(baseDir, e.Name(), fname) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} + // --- 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..437ab39343 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle_test.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle_test.go @@ -542,3 +542,105 @@ func TestWaitForTranscriptFlush_NonexistentFile_ReturnsImmediately(t *testing.T) t.Errorf("expected immediate return for nonexistent file, but took %v", elapsed) } } + +func TestFindTranscriptByID(t *testing.T) { + t.Parallel() + + base := t.TempDir() + // projects//.jsonl is the actual stored location + storedDir := filepath.Join(base, "-Users-foo-Development-repo") + wrongDir := filepath.Join(base, "-Users-foo-Development-repo--claude-worktrees-feature") + if err := os.MkdirAll(storedDir, 0o755); err != nil { + t.Fatalf("mkdir stored: %v", err) + } + if err := os.MkdirAll(wrongDir, 0o755); err != nil { + t.Fatalf("mkdir wrong: %v", err) + } + sessionID := "abc-123-def" + transcript := filepath.Join(storedDir, sessionID+".jsonl") + if err := os.WriteFile(transcript, []byte(`{"type":"human"}`+"\n"), 0o600); err != nil { + t.Fatalf("write transcript: %v", err) + } + + got := findTranscriptByID(base, sessionID) + if got != transcript { + t.Errorf("findTranscriptByID = %q, want %q", got, transcript) + } +} + +func TestFindTranscriptByID_NoMatch(t *testing.T) { + t.Parallel() + + base := t.TempDir() + if err := os.MkdirAll(filepath.Join(base, "some-project"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if got := findTranscriptByID(base, "missing-id"); got != "" { + t.Errorf("expected empty result for missing id, got %q", got) + } +} + +func TestFindTranscriptByID_NonexistentBase(t *testing.T) { + t.Parallel() + + if got := findTranscriptByID("/definitely/not/a/real/path", "anything"); got != "" { + t.Errorf("expected empty result for nonexistent base dir, 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) + } +} From 8fb819d0e1e1599c0aeb96f5bcbacca7e128466f Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Fri, 8 May 2026 17:04:37 +0200 Subject: [PATCH 2/7] Tighten transcript fallback gates per review - Only fall back on os.IsNotExist; pass through other Stat errors so permission/IO problems aren't masked by a directory scan. - Validate sessionID with validation.ValidateAgentSessionID before using it in filepath.Join, so a hostile hook payload can't traverse out of the projects base dir during the scan. - Skip the scan when the reported path isn't under the Claude projects base dir; for any other path the scan can't produce a better answer. Adds unit tests covering the outside-base-dir and traversal-id paths. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 3aa3df224dc5 --- cmd/entire/cli/agent/claudecode/lifecycle.go | 23 ++++++++- .../cli/agent/claudecode/lifecycle_test.go | 50 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index 0ce971ae4e..3065d3e75a 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -13,6 +13,7 @@ import ( "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. @@ -192,18 +193,36 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error // transcript_path encodes the worktree CWD (e.g. .claude/worktrees/) but // the file is stored under the parent repo's project dir. Falls back to scanning // ~/.claude/projects/*/.jsonl when the reported path doesn't exist. +// +// The fallback is gated to keep cost and risk minimal: +// - Only triggers on os.IsNotExist (permission/IO errors are returned as-is so +// real problems aren't masked by a scan). +// - Only triggers when the reported path is under the Claude projects base +// dir; for any other path the scan can't produce a better answer. +// - The session ID is validated with validation.ValidateAgentSessionID before +// being used in filepath.Join, blocking traversal via hostile hook input. 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 } - found := findTranscriptByID(base, sessionID) + cleanedRef := filepath.Clean(sessionRef) + cleanedBase := filepath.Clean(base) + if !strings.HasPrefix(cleanedRef, cleanedBase+string(os.PathSeparator)) { + return sessionRef + } + found := findTranscriptByID(cleanedBase, sessionID) if found == "" { return sessionRef } @@ -218,6 +237,8 @@ func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) st // findTranscriptByID scans baseDir's immediate child directories for a file // named ".jsonl" and returns the first match, or "" if none. +// Callers must validate sessionID with validation.ValidateAgentSessionID first +// — this function trusts the input and uses it verbatim in filepath.Join. func findTranscriptByID(baseDir, sessionID string) string { entries, err := os.ReadDir(baseDir) if err != nil { diff --git a/cmd/entire/cli/agent/claudecode/lifecycle_test.go b/cmd/entire/cli/agent/claudecode/lifecycle_test.go index 437ab39343..7839042d6a 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle_test.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle_test.go @@ -644,3 +644,53 @@ func TestResolveTranscriptPath_WorktreeFallback(t *testing.T) { t.Errorf("resolveTranscriptPath = %q, want %q (real location)", got, realPath) } } + +// 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) + } +} From 49112340d142b3f7e2f6eabce3c40ede0608befa Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 8 May 2026 18:22:36 +0200 Subject: [PATCH 3/7] improved claude worktree handling Entire-Checkpoint: 74920b2829ad --- cmd/entire/cli/agent/claudecode/lifecycle.go | 85 +++++++++------ .../cli/agent/claudecode/lifecycle_test.go | 92 +++++++++++----- .../claude_worktree_path_test.go | 102 ++++++++++++++++++ 3 files changed, 219 insertions(+), 60 deletions(-) create mode 100644 cmd/entire/cli/integration_test/claude_worktree_path_test.go diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index 3065d3e75a..8b8046fab4 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -188,19 +188,33 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error return event, nil } +// claudeWorktreeMarker is the substring that appears in an encoded project-dir +// segment when Claude Code is invoked from inside its worktree feature. +// SanitizePathForClaude replaces every non-alphanumeric character with '-', +// so a CWD ending in "/.claude/worktrees/" produces a project segment +// containing "--claude-worktrees-". +const claudeWorktreeMarker = "--claude-worktrees-" + // resolveTranscriptPath returns the canonical path to a Claude transcript file, // recovering from the Claude Code worktree-feature mismatch where // transcript_path encodes the worktree CWD (e.g. .claude/worktrees/) but -// the file is stored under the parent repo's project dir. Falls back to scanning -// ~/.claude/projects/*/.jsonl when the reported path doesn't exist. +// the file is stored under the parent repo's project dir. +// +// When the reported path doesn't exist, the resolver strips the worktree marker +// from the project segment and checks the parent-repo candidate. The lookup is +// fully deterministic — no directory scanning, no chance of crossing into an +// unrelated project that happens to share a session ID. // -// The fallback is gated to keep cost and risk minimal: -// - Only triggers on os.IsNotExist (permission/IO errors are returned as-is so -// real problems aren't masked by a scan). +// The fallback is gated to keep risk minimal: +// - Only triggers on os.IsNotExist (permission/IO errors are returned as-is +// so real problems aren't masked). // - Only triggers when the reported path is under the Claude projects base -// dir; for any other path the scan can't produce a better answer. -// - The session ID is validated with validation.ValidateAgentSessionID before -// being used in filepath.Join, blocking traversal via hostile hook input. +// dir. +// - Only triggers when the project segment contains claudeWorktreeMarker. +// - The session ID is validated with validation.ValidateAgentSessionID +// before being used in filepath.Join, blocking traversal via hostile hook +// input. +// - The candidate path must itself exist before being returned. func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) string { if sessionRef == "" || sessionID == "" { return sessionRef @@ -217,44 +231,49 @@ func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) st if err != nil { return sessionRef } - cleanedRef := filepath.Clean(sessionRef) - cleanedBase := filepath.Clean(base) - if !strings.HasPrefix(cleanedRef, cleanedBase+string(os.PathSeparator)) { + candidate := worktreeParentCandidate(filepath.Clean(base), filepath.Clean(sessionRef), sessionID) + if candidate == "" { return sessionRef } - found := findTranscriptByID(cleanedBase, sessionID) - if found == "" { + if _, err := os.Stat(candidate); err != nil { return sessionRef } logging.Info(logging.WithComponent(context.Background(), "agent.claudecode"), - "resolved transcript via fallback scan", + "resolved transcript via worktree fallback", slog.String("reported", sessionRef), - slog.String("found", found), + slog.String("found", candidate), slog.String("session_id", sessionID), ) - return found + return candidate } -// findTranscriptByID scans baseDir's immediate child directories for a file -// named ".jsonl" and returns the first match, or "" if none. -// Callers must validate sessionID with validation.ValidateAgentSessionID first -// — this function trusts the input and uses it verbatim in filepath.Join. -func findTranscriptByID(baseDir, sessionID string) string { - entries, err := os.ReadDir(baseDir) - if err != nil { +// worktreeParentCandidate returns the parent-repo equivalent of a reported +// transcript path when the project segment carries the Claude Code worktree +// marker. Returns "" if reported is not under base, has no project segment, +// or has no worktree marker. Callers must validate sessionID before calling. +// +// strings.LastIndex (not Index) is used because the *synthetic* worktree +// marker is always the trailing occurrence in the project segment: Claude +// appends "/.claude/worktrees/" to the cwd, and SanitizePathForClaude +// preserves left-to-right order, so the suffix carrying the bug is the last +// one. Cutting at the first match would mis-strip repos whose sanitized root +// already contains the token (e.g. repos checked out under a directory +// literally named "...--claude-worktrees-..."). +func worktreeParentCandidate(base, reported, sessionID string) string { + sep := string(os.PathSeparator) + prefix := base + sep + if !strings.HasPrefix(reported, prefix) { return "" } - fname := sessionID + ".jsonl" - for _, e := range entries { - if !e.IsDir() { - continue - } - candidate := filepath.Join(baseDir, e.Name(), fname) - if _, err := os.Stat(candidate); err == nil { - return candidate - } + projectSeg, _, ok := strings.Cut(reported[len(prefix):], sep) + if !ok || projectSeg == "" { + return "" + } + idx := strings.LastIndex(projectSeg, claudeWorktreeMarker) + if idx <= 0 { + return "" } - return "" + return filepath.Join(base, projectSeg[:idx], sessionID+".jsonl") } // --- Transcript flush sentinel --- diff --git a/cmd/entire/cli/agent/claudecode/lifecycle_test.go b/cmd/entire/cli/agent/claudecode/lifecycle_test.go index 7839042d6a..b4506fcd1c 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle_test.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle_test.go @@ -543,48 +543,64 @@ func TestWaitForTranscriptFlush_NonexistentFile_ReturnsImmediately(t *testing.T) } } -func TestFindTranscriptByID(t *testing.T) { +const testClaudeProjectsBase = "/home/u/.claude/projects" + +func TestWorktreeParentCandidate(t *testing.T) { t.Parallel() - base := t.TempDir() - // projects//.jsonl is the actual stored location - storedDir := filepath.Join(base, "-Users-foo-Development-repo") - wrongDir := filepath.Join(base, "-Users-foo-Development-repo--claude-worktrees-feature") - if err := os.MkdirAll(storedDir, 0o755); err != nil { - t.Fatalf("mkdir stored: %v", err) - } - if err := os.MkdirAll(wrongDir, 0o755); err != nil { - t.Fatalf("mkdir wrong: %v", err) - } - sessionID := "abc-123-def" - transcript := filepath.Join(storedDir, sessionID+".jsonl") - if err := os.WriteFile(transcript, []byte(`{"type":"human"}`+"\n"), 0o600); err != nil { - t.Fatalf("write transcript: %v", err) + 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) } +} - got := findTranscriptByID(base, sessionID) - if got != transcript { - t.Errorf("findTranscriptByID = %q, want %q", got, transcript) +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 TestFindTranscriptByID_NoMatch(t *testing.T) { +func TestWorktreeParentCandidate_OutsideBase(t *testing.T) { t.Parallel() - base := t.TempDir() - if err := os.MkdirAll(filepath.Join(base, "some-project"), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) + if got := worktreeParentCandidate("/a/base", "/somewhere/else/sess-1.jsonl", "sess-1"); got != "" { + t.Errorf("expected empty (outside base), got %q", got) } - if got := findTranscriptByID(base, "missing-id"); got != "" { - t.Errorf("expected empty result for missing id, 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 TestFindTranscriptByID_NonexistentBase(t *testing.T) { +func TestWorktreeParentCandidate_MarkerAtStart(t *testing.T) { t.Parallel() - if got := findTranscriptByID("/definitely/not/a/real/path", "anything"); got != "" { - t.Errorf("expected empty result for nonexistent base dir, got %q", got) + // 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) } } @@ -645,6 +661,28 @@ func TestResolveTranscriptPath_WorktreeFallback(t *testing.T) { } } +// 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. 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") + } +} From 2123e16baeef5b62d3d0a51c0fcbdb77e077b80e Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Mon, 11 May 2026 14:54:46 +0200 Subject: [PATCH 4/7] Anchor .entire/ paths at main worktree root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git hooks fired from inside a linked git worktree (e.g. Claude Code's .claude/worktrees/ feature) silently bailed: settings.IsSetUp resolved .entire/settings.json via show-toplevel of the linked worktree, which has no .entire/, so IsSetUpAndEnabled returned false and prepare-commit-msg/post-commit/pre-push all skipped their work. User commits made inside the worktree never got an Entire-Checkpoint trailer, were never condensed, and entire/checkpoints/v1 never advanced on push. paths.AbsPath now routes any .entire/* relative path through a new MainWorktreeRoot helper (parent of git --git-common-dir), keeping all other paths anchored at WorktreeRoot. .entire/ is a main-repo concern — linked worktrees should share the canonical config and state directory. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 84a26a76972c --- .../linked_worktree_setup_test.go | 73 ++++++++ cmd/entire/cli/paths/paths.go | 88 +++++++++- cmd/entire/cli/paths/paths_test.go | 162 ++++++++++++++++++ 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/integration_test/linked_worktree_setup_test.go 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/paths/paths.go b/cmd/entire/cli/paths/paths.go index 039c6fadae..233f5ee6cb 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 isEntireRelPath(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 @@ -142,6 +220,14 @@ func AbsPath(ctx context.Context, relPath string) (string, error) { return filepath.Join(root, relPath), nil } +// isEntireRelPath reports whether a relative path refers to the .entire +// directory or anything under it. Used by AbsPath to anchor .entire/* state +// at the main worktree root. +func isEntireRelPath(relPath string) bool { + clean := filepath.ToSlash(filepath.Clean(relPath)) + return clean == EntireDir || strings.HasPrefix(clean, EntireDir+"/") +} + // IsInfrastructurePath returns true if the path is part of CLI infrastructure // (i.e., inside the .entire directory) func IsInfrastructurePath(path string) bool { diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index b442c660a4..2175d8ee97 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,163 @@ func TestNormalizeMSYSPath(t *testing.T) { }) } } + +func TestIsEntireRelPath(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 := isEntireRelPath(tt.in); got != tt.want { + t.Errorf("isEntireRelPath(%q) = %v, want %v", 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 +} From 9441aadc23420245e661dfbe1e3cd08d4129b990 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Mon, 11 May 2026 15:24:28 +0200 Subject: [PATCH 5/7] Match main-worktree sessions from linked-worktree hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even after .entire/ paths were anchored at the main worktree, hooks firing from inside an agent-managed worktree (e.g. Claude Code's .claude/worktrees/) still silently bailed at the next layer: findSessionsForWorktree strict-matched on WorktreePath, so a commit made in the linked worktree could not find the session whose UserPromptSubmit had fired from the main worktree. The user-visible symptom: no Entire-Checkpoint trailer on the commit, no condensation, no metadata pushed for that session. findSessionsForWorktree now widens to also accept sessions registered against the main worktree when invoked from a linked worktree. The widening is one-directional — sibling linked worktrees don't bleed into each other, and main-worktree lookups are unchanged — so commits on main can't accidentally link to an unrelated agent session. logging.Init also moves from WorktreeRoot to MainWorktreeRoot so hook log output writes to the canonical .entire/logs/entire.log instead of spawning an orphan .entire/logs directory inside each linked worktree. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 6f37d51c79a0 --- cmd/entire/cli/logging/logger.go | 16 +- .../manual_commit_linked_worktree_test.go | 144 ++++++++++++++++++ .../cli/strategy/manual_commit_session.go | 27 ++++ 3 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go 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/strategy/manual_commit_linked_worktree_test.go b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go new file mode 100644 index 0000000000..d3f9918e19 --- /dev/null +++ b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go @@ -0,0 +1,144 @@ +package strategy + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/session" +) + +// TestFindSessionsForWorktree_LinkedWorktreeMatchesMain verifies that a hook +// firing from inside a linked worktree (such as Claude Code's +// .claude/worktrees/ agent feature) can still find sessions whose +// WorktreePath was registered against the main worktree. +// +// 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_LinkedWorktreeMatchesMain(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) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index f446529110..4a05e5f6f9 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -133,16 +133,43 @@ func countWarnableStaleEndedSessions(repo *git.Repository, sessions []*SessionSt } // findSessionsForWorktree finds all sessions for the given worktree path. +// +// When invoked from a linked worktree (where the current worktree differs from +// the main worktree resolved via git --git-common-dir), sessions registered +// against the main worktree are also returned. 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. +// +// Sessions registered against unrelated linked worktrees (e.g. a sibling +// worktree) are never returned: the widening is one-directional (linked → +// main), so commits in worktree A don't pick up sessions from worktree B. +// From the main worktree, behavior is unchanged: only sessions whose +// WorktreePath equals the main path are returned. func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, worktreePath string) ([]*SessionState, error) { allStates, err := s.listAllSessionStates(ctx) if err != nil { return nil, err } + // Resolve the main worktree path to decide whether to widen the match. + // Failure to resolve degrades gracefully to exact match — i.e. the prior + // behavior — so this fallback can't make any caller worse off. + mainPath := "" + if p, mainErr := paths.MainWorktreeRoot(ctx); mainErr == nil { + mainPath = p + } + widenToMain := mainPath != "" && mainPath != worktreePath + var matching []*SessionState for _, state := range allStates { if state.WorktreePath == worktreePath { matching = append(matching, state) + continue + } + if widenToMain && state.WorktreePath == mainPath { + matching = append(matching, state) } } return matching, nil From f3ec8a5fc451d366661343ad8339db37d6582f32 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Mon, 11 May 2026 16:08:10 +0200 Subject: [PATCH 6/7] Simplify review changes: dedupe + tighten docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop isEntireRelPath in favor of the existing IsSubpath helper. Same semantics on every test input (siblings, parent escapes, absolute paths, look-alikes), so no behavior change — just less code reinventing path containment. - Trim resolveTranscriptPath and worktreeParentCandidate docstrings: cut the bulleted gating recital and the WHAT-it-does narration, keep the WHY (Claude Code transcript_path bug, LastIndex rationale inline at the choice site). Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: b001dc12437e --- cmd/entire/cli/agent/claudecode/lifecycle.go | 57 +++++++------------- cmd/entire/cli/paths/paths.go | 10 +--- cmd/entire/cli/paths/paths_test.go | 11 ++-- 3 files changed, 29 insertions(+), 49 deletions(-) diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index 8b8046fab4..b2bc4c708e 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -188,33 +188,20 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error return event, nil } -// claudeWorktreeMarker is the substring that appears in an encoded project-dir -// segment when Claude Code is invoked from inside its worktree feature. -// SanitizePathForClaude replaces every non-alphanumeric character with '-', -// so a CWD ending in "/.claude/worktrees/" produces a project segment -// containing "--claude-worktrees-". +// 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 returns the canonical path to a Claude transcript file, -// recovering from the Claude Code worktree-feature mismatch where -// transcript_path encodes the worktree CWD (e.g. .claude/worktrees/) but -// the file is stored under the parent repo's project dir. -// -// When the reported path doesn't exist, the resolver strips the worktree marker -// from the project segment and checks the parent-repo candidate. The lookup is -// fully deterministic — no directory scanning, no chance of crossing into an -// unrelated project that happens to share a session ID. -// -// The fallback is gated to keep risk minimal: -// - Only triggers on os.IsNotExist (permission/IO errors are returned as-is -// so real problems aren't masked). -// - Only triggers when the reported path is under the Claude projects base -// dir. -// - Only triggers when the project segment contains claudeWorktreeMarker. -// - The session ID is validated with validation.ValidateAgentSessionID -// before being used in filepath.Join, blocking traversal via hostile hook -// input. -// - The candidate path must itself exist before being returned. +// 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 @@ -247,18 +234,9 @@ func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) st return candidate } -// worktreeParentCandidate returns the parent-repo equivalent of a reported -// transcript path when the project segment carries the Claude Code worktree -// marker. Returns "" if reported is not under base, has no project segment, -// or has no worktree marker. Callers must validate sessionID before calling. -// -// strings.LastIndex (not Index) is used because the *synthetic* worktree -// marker is always the trailing occurrence in the project segment: Claude -// appends "/.claude/worktrees/" to the cwd, and SanitizePathForClaude -// preserves left-to-right order, so the suffix carrying the bug is the last -// one. Cutting at the first match would mis-strip repos whose sanitized root -// already contains the token (e.g. repos checked out under a directory -// literally named "...--claude-worktrees-..."). +// 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 @@ -269,6 +247,11 @@ func worktreeParentCandidate(base, reported, sessionID string) string { 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 "" diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 233f5ee6cb..95fcc9a673 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -204,7 +204,7 @@ func AbsPath(ctx context.Context, relPath string) (string, error) { return relPath, nil } - if isEntireRelPath(relPath) { + if IsSubpath(EntireDir, relPath) { root, err := MainWorktreeRoot(ctx) if err != nil { return "", err @@ -220,14 +220,6 @@ func AbsPath(ctx context.Context, relPath string) (string, error) { return filepath.Join(root, relPath), nil } -// isEntireRelPath reports whether a relative path refers to the .entire -// directory or anything under it. Used by AbsPath to anchor .entire/* state -// at the main worktree root. -func isEntireRelPath(relPath string) bool { - clean := filepath.ToSlash(filepath.Clean(relPath)) - return clean == EntireDir || strings.HasPrefix(clean, EntireDir+"/") -} - // IsInfrastructurePath returns true if the path is part of CLI infrastructure // (i.e., inside the .entire directory) func IsInfrastructurePath(path string) bool { diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index 2175d8ee97..eeab062d40 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -201,7 +201,12 @@ func TestNormalizeMSYSPath(t *testing.T) { } } -func TestIsEntireRelPath(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 @@ -222,8 +227,8 @@ func TestIsEntireRelPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := isEntireRelPath(tt.in); got != tt.want { - t.Errorf("isEntireRelPath(%q) = %v, want %v", tt.in, got, tt.want) + if got := IsSubpath(EntireDir, tt.in); got != tt.want { + t.Errorf("IsSubpath(%q, %q) = %v, want %v", EntireDir, tt.in, got, tt.want) } }) } From a8a980d86421ecbd8a8852296598ab8a84b12f04 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 11 May 2026 17:23:24 +0200 Subject: [PATCH 7/7] Only fall back to main-worktree sessions when linked has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findSessionsForWorktree was unconditionally widening linked → main: from any linked worktree it returned both linked-worktree sessions AND main-worktree sessions. That fixed Claude's worktrees-feature path (outer session in main, inner subprocess commits from linked) but had two unintended consequences for users who run agents in linked worktrees independently: 1. Bundling. A commit in `git worktree add ../wt && cd ../wt && agent` would silently attach any active main-worktree session to its checkpoint, mixing unrelated session context. The multi-session model is documented as "sessions in the same directory interleave" — different worktrees are different directories. 2. BaseCommit corruption. postCommitUpdateBaseCommitOnly (fired when a commit has no Entire-Checkpoint trailer) clobbers state.BaseCommit to the new HEAD for every session it sees. With the widened lookup, a no-trailer commit in a linked worktree would advance main's active session's BaseCommit to a commit on the linked branch. Future prepare-commit-msg on main then filters that session out (BaseCommit != HEAD), silently killing it. Switch to fallback-only semantics: prefer strict-equal matches, fall back to main only when the linked worktree has no session of its own. Preserves the Claude's-worktrees-feature fix (linked has no session → fallback finds the outer session in main), while keeping linked-worktree sessions and main-worktree sessions isolated when both exist. The fallback stays one-directional. Main-worktree lookups are unchanged and sibling linked worktrees still never bleed into each other. Tests: - Rename _LinkedWorktreeMatchesMain → _LinkedWorktreeFallsBackToMain (the semantic is now fallback, not widening). - Add _LinkedWorktreeWithOwnSessionIgnoresMain to lock in property 1. - Add _LinkedFallbackSkipsSiblings: when neither linked nor main has a session, return empty rather than wandering into a sibling worktree's session. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: b6ccb16e9ce3 --- .../manual_commit_linked_worktree_test.go | 133 +++++++++++++++++- .../cli/strategy/manual_commit_session.go | 62 ++++---- 2 files changed, 166 insertions(+), 29 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go index d3f9918e19..dbd405b524 100644 --- a/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go +++ b/cmd/entire/cli/strategy/manual_commit_linked_worktree_test.go @@ -9,17 +9,18 @@ import ( "github.com/entireio/cli/cmd/entire/cli/session" ) -// TestFindSessionsForWorktree_LinkedWorktreeMatchesMain verifies that a hook -// firing from inside a linked worktree (such as Claude Code's -// .claude/worktrees/ agent feature) can still find sessions whose -// WorktreePath was registered against the main worktree. +// 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_LinkedWorktreeMatchesMain(t *testing.T) { +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. @@ -142,3 +143,125 @@ func TestFindSessionsForWorktree_MainWorktreeUnchanged(t *testing.T) { 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 4a05e5f6f9..4d89cd3228 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -134,45 +134,59 @@ func countWarnableStaleEndedSessions(repo *git.Repository, sessions []*SessionSt // findSessionsForWorktree finds all sessions for the given worktree path. // -// When invoked from a linked worktree (where the current worktree differs from -// the main worktree resolved via git --git-common-dir), sessions registered -// against the main worktree are also returned. 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. +// 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. // -// Sessions registered against unrelated linked worktrees (e.g. a sibling -// worktree) are never returned: the widening is one-directional (linked → -// main), so commits in worktree A don't pick up sessions from worktree B. -// From the main worktree, behavior is unchanged: only sessions whose -// WorktreePath equals the main path are returned. +// 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 } - // Resolve the main worktree path to decide whether to widen the match. - // Failure to resolve degrades gracefully to exact match — i.e. the prior - // behavior — so this fallback can't make any caller worse off. + var localMatches []*SessionState + for _, state := range allStates { + if state.WorktreePath == worktreePath { + localMatches = append(localMatches, state) + } + } + 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 } - widenToMain := mainPath != "" && mainPath != worktreePath + if mainPath == "" || mainPath == worktreePath { + return nil, nil + } - var matching []*SessionState + var mainMatches []*SessionState for _, state := range allStates { - if state.WorktreePath == worktreePath { - matching = append(matching, state) - continue - } - if widenToMain && state.WorktreePath == mainPath { - matching = append(matching, state) + if state.WorktreePath == mainPath { + mainMatches = append(mainMatches, state) } } - return matching, nil + return mainMatches, nil } type rewritePair struct {