diff --git a/cli/agent/factoryaidroid/transcript_2_test.go b/cli/agent/factoryaidroid/transcript_2_test.go
new file mode 100644
index 0000000..4a5ce80
--- /dev/null
+++ b/cli/agent/factoryaidroid/transcript_2_test.go
@@ -0,0 +1,490 @@
+package factoryaidroid
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/transcript"
+)
+
+// makeWriteToolLine returns a Droid-format JSONL line with a Write tool_use for the given file.
+func makeWriteToolLine(t *testing.T, id, filePath string) string {
+ t.Helper()
+ return makeFileToolLine(t, "Write", id, filePath)
+}
+
+// makeEditToolLine returns a Droid-format JSONL line with an Edit tool_use for the given file.
+func makeEditToolLine(t *testing.T, id, filePath string) string {
+ t.Helper()
+ return makeFileToolLine(t, "Edit", id, filePath)
+}
+
+// makeTaskToolUseLine returns a Droid-format JSONL line with a Task tool_use (spawning a subagent).
+func makeTaskToolUseLine(t *testing.T, id, toolUseID string) string {
+ t.Helper()
+ innerMsg := mustMarshal(t, map[string]interface{}{
+ "role": "assistant",
+ "content": []map[string]interface{}{
+ {
+ "type": "tool_use",
+ "id": toolUseID,
+ "name": "Task",
+ "input": map[string]string{"prompt": "do something"},
+ },
+ },
+ })
+ line := mustMarshal(t, map[string]interface{}{
+ "type": "message",
+ "id": id,
+ "message": json.RawMessage(innerMsg),
+ })
+ return string(line)
+}
+
+// makeTaskResultLine returns a Droid-format JSONL user line with a tool_result containing agentId.
+func makeTaskResultLine(t *testing.T, id, toolUseID, agentID string) string {
+ t.Helper()
+ innerMsg := mustMarshal(t, map[string]interface{}{
+ "role": "user",
+ "content": []map[string]interface{}{
+ {
+ "type": "tool_result",
+ "tool_use_id": toolUseID,
+ "content": "agentId: " + agentID,
+ },
+ },
+ })
+ line := mustMarshal(t, map[string]interface{}{
+ "type": "message",
+ "id": id,
+ "message": json.RawMessage(innerMsg),
+ })
+ return string(line)
+}
+
+// makeUserTextLine returns a Droid-format JSONL line with a user text message (array content).
+func makeUserTextLine(t *testing.T, id, text string) string {
+ t.Helper()
+ innerMsg := mustMarshal(t, map[string]interface{}{
+ "role": "user",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": text},
+ },
+ })
+ line := mustMarshal(t, map[string]interface{}{
+ "type": "message",
+ "id": id,
+ "message": json.RawMessage(innerMsg),
+ })
+ return string(line)
+}
+
+// makeAssistantTextLine returns a Droid-format JSONL line with an assistant text message.
+func makeAssistantTextLine(t *testing.T, id, text string) string {
+ t.Helper()
+ innerMsg := mustMarshal(t, map[string]interface{}{
+ "role": "assistant",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": text},
+ },
+ })
+ line := mustMarshal(t, map[string]interface{}{
+ "type": "message",
+ "id": id,
+ "message": json.RawMessage(innerMsg),
+ })
+ return string(line)
+}
+
+// makeAssistantTokenLine returns a Droid-format JSONL line with an assistant message that has usage data.
+func makeAssistantTokenLine(t *testing.T, id, msgID string, inputTokens, outputTokens int) string {
+ t.Helper()
+ innerMsg := mustMarshal(t, map[string]interface{}{
+ "role": "assistant",
+ "id": msgID,
+ "usage": map[string]int{
+ "input_tokens": inputTokens,
+ "output_tokens": outputTokens,
+ },
+ })
+ line := mustMarshal(t, map[string]interface{}{
+ "type": "message",
+ "id": id,
+ "message": json.RawMessage(innerMsg),
+ })
+ return string(line)
+}
+
+func TestExtractPrompts(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+
+ writeJSONLFile(
+ t, transcriptPath,
+ makeUserTextLine(t, "u1", "Fix the login bug"),
+ makeAssistantTextLine(t, "a1", "I'll fix the login bug."),
+ makeUserTextLine(t, "u2", "Now add tests"),
+ )
+
+ ag := &FactoryAIDroidAgent{}
+ prompts, err := ag.ExtractPrompts(transcriptPath, 0)
+ if err != nil {
+ t.Fatalf("ExtractPrompts() error = %v", err)
+ }
+
+ if len(prompts) != 2 {
+ t.Fatalf("ExtractPrompts() got %d prompts, want 2", len(prompts))
+ }
+ if prompts[0] != "Fix the login bug" {
+ t.Errorf("prompts[0] = %q, want %q", prompts[0], "Fix the login bug")
+ }
+ if prompts[1] != "Now add tests" {
+ t.Errorf("prompts[1] = %q, want %q", prompts[1], "Now add tests")
+ }
+}
+
+func TestExtractPrompts_StripsIDETags(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+
+ // User message with IDE context tags injected by VSCode extension
+ promptWithTags := `/repo/main.goFix the bug`
+ writeJSONLFile(
+ t, transcriptPath,
+ makeUserTextLine(t, "u1", promptWithTags),
+ )
+
+ ag := &FactoryAIDroidAgent{}
+ prompts, err := ag.ExtractPrompts(transcriptPath, 0)
+ if err != nil {
+ t.Fatalf("ExtractPrompts() error = %v", err)
+ }
+
+ if len(prompts) != 1 {
+ t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts))
+ }
+ if prompts[0] != "Fix the bug" {
+ t.Errorf("prompts[0] = %q, want %q (IDE tags should be stripped)", prompts[0], "Fix the bug")
+ }
+}
+
+func TestExtractPrompts_WithOffset(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+
+ writeJSONLFile(
+ t, transcriptPath,
+ makeUserTextLine(t, "u1", "First prompt"),
+ makeAssistantTextLine(t, "a1", "Done."),
+ makeUserTextLine(t, "u2", "Second prompt"),
+ makeAssistantTextLine(t, "a2", "Done again."),
+ )
+
+ ag := &FactoryAIDroidAgent{}
+ // Skip first 2 lines (first user+assistant turn)
+ prompts, err := ag.ExtractPrompts(transcriptPath, 2)
+ if err != nil {
+ t.Fatalf("ExtractPrompts() error = %v", err)
+ }
+
+ if len(prompts) != 1 {
+ t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts))
+ }
+ if prompts[0] != "Second prompt" {
+ t.Errorf("prompts[0] = %q, want %q", prompts[0], "Second prompt")
+ }
+}
+
+func TestExtractSummary(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+
+ writeJSONLFile(
+ t, transcriptPath,
+ makeUserTextLine(t, "u1", "Fix the bug"),
+ makeAssistantTextLine(t, "a1", "Working on it..."),
+ makeUserTextLine(t, "u2", "Thanks"),
+ makeAssistantTextLine(t, "a2", "All done! The login bug is fixed."),
+ )
+
+ ag := &FactoryAIDroidAgent{}
+ summary, err := ag.ExtractSummary(transcriptPath)
+ if err != nil {
+ t.Fatalf("ExtractSummary() error = %v", err)
+ }
+
+ if summary != "All done! The login bug is fixed." {
+ t.Errorf("ExtractSummary() = %q, want %q", summary, "All done! The login bug is fixed.")
+ }
+}
+
+func TestExtractSummary_SkipsToolUseBlocks(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+
+ // Last assistant message has tool_use (no text), second-to-last has text
+ writeJSONLFile(
+ t, transcriptPath,
+ makeUserTextLine(t, "u1", "Edit main.go"),
+ makeAssistantTextLine(t, "a1", "I updated the file."),
+ makeWriteToolLine(t, "a2", "/repo/main.go"),
+ )
+
+ ag := &FactoryAIDroidAgent{}
+ summary, err := ag.ExtractSummary(transcriptPath)
+ if err != nil {
+ t.Fatalf("ExtractSummary() error = %v", err)
+ }
+
+ // Should find "I updated the file." since the tool_use message has no text block
+ if summary != "I updated the file." {
+ t.Errorf("ExtractSummary() = %q, want %q", summary, "I updated the file.")
+ }
+}
+
+func TestExtractSummary_EmptyTranscript(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+ if err := os.WriteFile(transcriptPath, []byte(""), 0o600); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ ag := &FactoryAIDroidAgent{}
+ summary, err := ag.ExtractSummary(transcriptPath)
+ if err != nil {
+ t.Fatalf("ExtractSummary() error = %v", err)
+ }
+
+ if summary != "" {
+ t.Errorf("ExtractSummary() = %q, want empty string", summary)
+ }
+}
+
+func TestParseDroidTranscript_MalformedLines(t *testing.T) {
+ t.Parallel()
+
+ // Transcript with some broken JSON lines interspersed with valid ones
+ data := []byte(
+ `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" +
+ `{"broken json` + "\n" +
+ `not even close to json` + "\n" +
+ `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}` + "\n" +
+ `{"type":"session_event","data":"ignored"}` + "\n",
+ )
+
+ lines, _, err := ParseDroidTranscriptFromBytes(data, 0)
+ if err != nil {
+ t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err)
+ }
+
+ // Only the 2 valid "message" type lines should be parsed
+ if len(lines) != 2 {
+ t.Fatalf("got %d lines, want 2 (malformed lines should be silently skipped)", len(lines))
+ }
+ if lines[0].Type != transcript.TypeUser {
+ t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser)
+ }
+ if lines[1].Type != transcript.TypeAssistant {
+ t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant)
+ }
+}
+
+func TestCalculateTotalTokenUsageFromTranscript_WithSubagentFiles(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/transcript.jsonl"
+ subagentsDir := tmpDir + "/tasks/toolu_task1"
+
+ if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
+ t.Fatalf("failed to create subagents dir: %v", err)
+ }
+
+ // Main transcript: assistant message with tokens + Task spawning subagent "sub1"
+ writeJSONLFile(
+ t, transcriptPath,
+ makeAssistantTokenLine(t, "a1", "msg_main1", 100, 50),
+ makeTaskToolUseLine(t, "a2", "toolu_task2"),
+ makeTaskResultLine(t, "u2", "toolu_task2", "sub99"),
+ )
+
+ // Subagent transcript: assistant message with its own tokens
+ writeJSONLFile(
+ t, subagentsDir+"/agent-sub99.jsonl",
+ makeAssistantTokenLine(t, "sa1", "msg_sub1", 200, 80),
+ makeAssistantTokenLine(t, "sa2", "msg_sub2", 150, 60),
+ )
+
+ usage, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 0, subagentsDir)
+ if err != nil {
+ t.Fatalf("CalculateTotalTokenUsageFromTranscript() error: %v", err)
+ }
+
+ // Main agent: 100 input, 50 output, 1 API call
+ if usage.InputTokens != 100 {
+ t.Errorf("main InputTokens = %d, want 100", usage.InputTokens)
+ }
+ if usage.OutputTokens != 50 {
+ t.Errorf("main OutputTokens = %d, want 50", usage.OutputTokens)
+ }
+ if usage.APICallCount != 1 {
+ t.Errorf("main APICallCount = %d, want 1", usage.APICallCount)
+ }
+
+ // Subagent tokens should be aggregated
+ if usage.SubagentTokens == nil {
+ t.Fatal("SubagentTokens is nil, expected subagent token data")
+ }
+ if usage.SubagentTokens.InputTokens != 350 {
+ t.Errorf("subagent InputTokens = %d, want 350 (200+150)", usage.SubagentTokens.InputTokens)
+ }
+ if usage.SubagentTokens.OutputTokens != 140 {
+ t.Errorf("subagent OutputTokens = %d, want 140 (80+60)", usage.SubagentTokens.OutputTokens)
+ }
+ if usage.SubagentTokens.APICallCount != 2 {
+ t.Errorf("subagent APICallCount = %d, want 2", usage.SubagentTokens.APICallCount)
+ }
+}
+
+func TestCleanModelName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ raw string
+ want string
+ }{
+ {
+ name: "custom prefix stripped",
+ raw: "custom:Gemini-2.5-Pro-0",
+ want: "Gemini-2.5-Pro-0",
+ },
+ {
+ name: "no prefix unchanged",
+ raw: "claude-opus-4-6",
+ want: "claude-opus-4-6",
+ },
+ {
+ name: "empty string",
+ raw: "",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := cleanModelName(tt.raw)
+ if got != tt.want {
+ t.Errorf("cleanModelName(%q) = %q, want %q", tt.raw, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractModelFromTranscript_SettingsFile(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/session.jsonl"
+ settingsPath := tmpDir + "/session.settings.json"
+
+ // Write a transcript file (content doesn't matter for model extraction)
+ if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Write the settings file with the model
+ settingsData := `{"model":"custom:Gemini-2.5-Pro-0","reasoningEffort":"none"}`
+ if err := os.WriteFile(settingsPath, []byte(settingsData), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ model := ExtractModelFromTranscript(transcriptPath)
+ if model != "Gemini-2.5-Pro-0" {
+ t.Errorf("ExtractModelFromTranscript() = %q, want %q", model, "Gemini-2.5-Pro-0")
+ }
+}
+
+func TestExtractModelFromTranscript_NoCustomPrefix(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/session.jsonl"
+ settingsPath := tmpDir + "/session.settings.json"
+
+ if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ settingsData := `{"model":"claude-opus-4-6"}`
+ if err := os.WriteFile(settingsPath, []byte(settingsData), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ model := ExtractModelFromTranscript(transcriptPath)
+ if model != "claude-opus-4-6" {
+ t.Errorf("ExtractModelFromTranscript() = %q, want %q", model, "claude-opus-4-6")
+ }
+}
+
+func TestExtractModelFromTranscript_NoSettingsFile(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/session.jsonl"
+
+ // Write transcript but no settings file
+ if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ model := ExtractModelFromTranscript(transcriptPath)
+ if model != "" {
+ t.Errorf("ExtractModelFromTranscript() = %q, want empty", model)
+ }
+}
+
+func TestExtractModelFromTranscript_CorruptSettingsFile(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := tmpDir + "/session.jsonl"
+ settingsPath := tmpDir + "/session.settings.json"
+
+ if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Write invalid JSON to the settings file
+ if err := os.WriteFile(settingsPath, []byte(`{not valid json`), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ model := ExtractModelFromTranscript(transcriptPath)
+ if model != "" {
+ t.Errorf("ExtractModelFromTranscript() = %q, want empty for corrupt settings", model)
+ }
+}
+
+func TestExtractModelFromTranscript_EmptyPath(t *testing.T) {
+ t.Parallel()
+
+ model := ExtractModelFromTranscript("")
+ if model != "" {
+ t.Errorf("ExtractModelFromTranscript(\"\") = %q, want empty", model)
+ }
+}
diff --git a/cli/agent/factoryaidroid/transcript_test.go b/cli/agent/factoryaidroid/transcript_test.go
index 8e65c30..789fb3b 100644
--- a/cli/agent/factoryaidroid/transcript_test.go
+++ b/cli/agent/factoryaidroid/transcript_test.go
@@ -804,484 +804,3 @@ func makeFileToolLine(t *testing.T, toolName, id, filePath string) string {
})
return string(line)
}
-
-// makeWriteToolLine returns a Droid-format JSONL line with a Write tool_use for the given file.
-func makeWriteToolLine(t *testing.T, id, filePath string) string {
- t.Helper()
- return makeFileToolLine(t, "Write", id, filePath)
-}
-
-// makeEditToolLine returns a Droid-format JSONL line with an Edit tool_use for the given file.
-func makeEditToolLine(t *testing.T, id, filePath string) string {
- t.Helper()
- return makeFileToolLine(t, "Edit", id, filePath)
-}
-
-// makeTaskToolUseLine returns a Droid-format JSONL line with a Task tool_use (spawning a subagent).
-func makeTaskToolUseLine(t *testing.T, id, toolUseID string) string {
- t.Helper()
- innerMsg := mustMarshal(t, map[string]interface{}{
- "role": "assistant",
- "content": []map[string]interface{}{
- {
- "type": "tool_use",
- "id": toolUseID,
- "name": "Task",
- "input": map[string]string{"prompt": "do something"},
- },
- },
- })
- line := mustMarshal(t, map[string]interface{}{
- "type": "message",
- "id": id,
- "message": json.RawMessage(innerMsg),
- })
- return string(line)
-}
-
-// makeTaskResultLine returns a Droid-format JSONL user line with a tool_result containing agentId.
-func makeTaskResultLine(t *testing.T, id, toolUseID, agentID string) string {
- t.Helper()
- innerMsg := mustMarshal(t, map[string]interface{}{
- "role": "user",
- "content": []map[string]interface{}{
- {
- "type": "tool_result",
- "tool_use_id": toolUseID,
- "content": "agentId: " + agentID,
- },
- },
- })
- line := mustMarshal(t, map[string]interface{}{
- "type": "message",
- "id": id,
- "message": json.RawMessage(innerMsg),
- })
- return string(line)
-}
-
-// makeUserTextLine returns a Droid-format JSONL line with a user text message (array content).
-func makeUserTextLine(t *testing.T, id, text string) string {
- t.Helper()
- innerMsg := mustMarshal(t, map[string]interface{}{
- "role": "user",
- "content": []map[string]interface{}{
- {"type": "text", "text": text},
- },
- })
- line := mustMarshal(t, map[string]interface{}{
- "type": "message",
- "id": id,
- "message": json.RawMessage(innerMsg),
- })
- return string(line)
-}
-
-// makeAssistantTextLine returns a Droid-format JSONL line with an assistant text message.
-func makeAssistantTextLine(t *testing.T, id, text string) string {
- t.Helper()
- innerMsg := mustMarshal(t, map[string]interface{}{
- "role": "assistant",
- "content": []map[string]interface{}{
- {"type": "text", "text": text},
- },
- })
- line := mustMarshal(t, map[string]interface{}{
- "type": "message",
- "id": id,
- "message": json.RawMessage(innerMsg),
- })
- return string(line)
-}
-
-// makeAssistantTokenLine returns a Droid-format JSONL line with an assistant message that has usage data.
-func makeAssistantTokenLine(t *testing.T, id, msgID string, inputTokens, outputTokens int) string {
- t.Helper()
- innerMsg := mustMarshal(t, map[string]interface{}{
- "role": "assistant",
- "id": msgID,
- "usage": map[string]int{
- "input_tokens": inputTokens,
- "output_tokens": outputTokens,
- },
- })
- line := mustMarshal(t, map[string]interface{}{
- "type": "message",
- "id": id,
- "message": json.RawMessage(innerMsg),
- })
- return string(line)
-}
-
-func TestExtractPrompts(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
-
- writeJSONLFile(
- t, transcriptPath,
- makeUserTextLine(t, "u1", "Fix the login bug"),
- makeAssistantTextLine(t, "a1", "I'll fix the login bug."),
- makeUserTextLine(t, "u2", "Now add tests"),
- )
-
- ag := &FactoryAIDroidAgent{}
- prompts, err := ag.ExtractPrompts(transcriptPath, 0)
- if err != nil {
- t.Fatalf("ExtractPrompts() error = %v", err)
- }
-
- if len(prompts) != 2 {
- t.Fatalf("ExtractPrompts() got %d prompts, want 2", len(prompts))
- }
- if prompts[0] != "Fix the login bug" {
- t.Errorf("prompts[0] = %q, want %q", prompts[0], "Fix the login bug")
- }
- if prompts[1] != "Now add tests" {
- t.Errorf("prompts[1] = %q, want %q", prompts[1], "Now add tests")
- }
-}
-
-func TestExtractPrompts_StripsIDETags(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
-
- // User message with IDE context tags injected by VSCode extension
- promptWithTags := `/repo/main.goFix the bug`
- writeJSONLFile(
- t, transcriptPath,
- makeUserTextLine(t, "u1", promptWithTags),
- )
-
- ag := &FactoryAIDroidAgent{}
- prompts, err := ag.ExtractPrompts(transcriptPath, 0)
- if err != nil {
- t.Fatalf("ExtractPrompts() error = %v", err)
- }
-
- if len(prompts) != 1 {
- t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts))
- }
- if prompts[0] != "Fix the bug" {
- t.Errorf("prompts[0] = %q, want %q (IDE tags should be stripped)", prompts[0], "Fix the bug")
- }
-}
-
-func TestExtractPrompts_WithOffset(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
-
- writeJSONLFile(
- t, transcriptPath,
- makeUserTextLine(t, "u1", "First prompt"),
- makeAssistantTextLine(t, "a1", "Done."),
- makeUserTextLine(t, "u2", "Second prompt"),
- makeAssistantTextLine(t, "a2", "Done again."),
- )
-
- ag := &FactoryAIDroidAgent{}
- // Skip first 2 lines (first user+assistant turn)
- prompts, err := ag.ExtractPrompts(transcriptPath, 2)
- if err != nil {
- t.Fatalf("ExtractPrompts() error = %v", err)
- }
-
- if len(prompts) != 1 {
- t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts))
- }
- if prompts[0] != "Second prompt" {
- t.Errorf("prompts[0] = %q, want %q", prompts[0], "Second prompt")
- }
-}
-
-func TestExtractSummary(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
-
- writeJSONLFile(
- t, transcriptPath,
- makeUserTextLine(t, "u1", "Fix the bug"),
- makeAssistantTextLine(t, "a1", "Working on it..."),
- makeUserTextLine(t, "u2", "Thanks"),
- makeAssistantTextLine(t, "a2", "All done! The login bug is fixed."),
- )
-
- ag := &FactoryAIDroidAgent{}
- summary, err := ag.ExtractSummary(transcriptPath)
- if err != nil {
- t.Fatalf("ExtractSummary() error = %v", err)
- }
-
- if summary != "All done! The login bug is fixed." {
- t.Errorf("ExtractSummary() = %q, want %q", summary, "All done! The login bug is fixed.")
- }
-}
-
-func TestExtractSummary_SkipsToolUseBlocks(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
-
- // Last assistant message has tool_use (no text), second-to-last has text
- writeJSONLFile(
- t, transcriptPath,
- makeUserTextLine(t, "u1", "Edit main.go"),
- makeAssistantTextLine(t, "a1", "I updated the file."),
- makeWriteToolLine(t, "a2", "/repo/main.go"),
- )
-
- ag := &FactoryAIDroidAgent{}
- summary, err := ag.ExtractSummary(transcriptPath)
- if err != nil {
- t.Fatalf("ExtractSummary() error = %v", err)
- }
-
- // Should find "I updated the file." since the tool_use message has no text block
- if summary != "I updated the file." {
- t.Errorf("ExtractSummary() = %q, want %q", summary, "I updated the file.")
- }
-}
-
-func TestExtractSummary_EmptyTranscript(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
- if err := os.WriteFile(transcriptPath, []byte(""), 0o600); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
-
- ag := &FactoryAIDroidAgent{}
- summary, err := ag.ExtractSummary(transcriptPath)
- if err != nil {
- t.Fatalf("ExtractSummary() error = %v", err)
- }
-
- if summary != "" {
- t.Errorf("ExtractSummary() = %q, want empty string", summary)
- }
-}
-
-func TestParseDroidTranscript_MalformedLines(t *testing.T) {
- t.Parallel()
-
- // Transcript with some broken JSON lines interspersed with valid ones
- data := []byte(
- `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" +
- `{"broken json` + "\n" +
- `not even close to json` + "\n" +
- `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}` + "\n" +
- `{"type":"session_event","data":"ignored"}` + "\n",
- )
-
- lines, _, err := ParseDroidTranscriptFromBytes(data, 0)
- if err != nil {
- t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err)
- }
-
- // Only the 2 valid "message" type lines should be parsed
- if len(lines) != 2 {
- t.Fatalf("got %d lines, want 2 (malformed lines should be silently skipped)", len(lines))
- }
- if lines[0].Type != transcript.TypeUser {
- t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser)
- }
- if lines[1].Type != transcript.TypeAssistant {
- t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant)
- }
-}
-
-func TestCalculateTotalTokenUsageFromTranscript_WithSubagentFiles(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/transcript.jsonl"
- subagentsDir := tmpDir + "/tasks/toolu_task1"
-
- if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
- t.Fatalf("failed to create subagents dir: %v", err)
- }
-
- // Main transcript: assistant message with tokens + Task spawning subagent "sub1"
- writeJSONLFile(
- t, transcriptPath,
- makeAssistantTokenLine(t, "a1", "msg_main1", 100, 50),
- makeTaskToolUseLine(t, "a2", "toolu_task2"),
- makeTaskResultLine(t, "u2", "toolu_task2", "sub99"),
- )
-
- // Subagent transcript: assistant message with its own tokens
- writeJSONLFile(
- t, subagentsDir+"/agent-sub99.jsonl",
- makeAssistantTokenLine(t, "sa1", "msg_sub1", 200, 80),
- makeAssistantTokenLine(t, "sa2", "msg_sub2", 150, 60),
- )
-
- usage, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 0, subagentsDir)
- if err != nil {
- t.Fatalf("CalculateTotalTokenUsageFromTranscript() error: %v", err)
- }
-
- // Main agent: 100 input, 50 output, 1 API call
- if usage.InputTokens != 100 {
- t.Errorf("main InputTokens = %d, want 100", usage.InputTokens)
- }
- if usage.OutputTokens != 50 {
- t.Errorf("main OutputTokens = %d, want 50", usage.OutputTokens)
- }
- if usage.APICallCount != 1 {
- t.Errorf("main APICallCount = %d, want 1", usage.APICallCount)
- }
-
- // Subagent tokens should be aggregated
- if usage.SubagentTokens == nil {
- t.Fatal("SubagentTokens is nil, expected subagent token data")
- }
- if usage.SubagentTokens.InputTokens != 350 {
- t.Errorf("subagent InputTokens = %d, want 350 (200+150)", usage.SubagentTokens.InputTokens)
- }
- if usage.SubagentTokens.OutputTokens != 140 {
- t.Errorf("subagent OutputTokens = %d, want 140 (80+60)", usage.SubagentTokens.OutputTokens)
- }
- if usage.SubagentTokens.APICallCount != 2 {
- t.Errorf("subagent APICallCount = %d, want 2", usage.SubagentTokens.APICallCount)
- }
-}
-
-func TestCleanModelName(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- raw string
- want string
- }{
- {
- name: "custom prefix stripped",
- raw: "custom:Gemini-2.5-Pro-0",
- want: "Gemini-2.5-Pro-0",
- },
- {
- name: "no prefix unchanged",
- raw: "claude-opus-4-6",
- want: "claude-opus-4-6",
- },
- {
- name: "empty string",
- raw: "",
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := cleanModelName(tt.raw)
- if got != tt.want {
- t.Errorf("cleanModelName(%q) = %q, want %q", tt.raw, got, tt.want)
- }
- })
- }
-}
-
-func TestExtractModelFromTranscript_SettingsFile(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/session.jsonl"
- settingsPath := tmpDir + "/session.settings.json"
-
- // Write a transcript file (content doesn't matter for model extraction)
- if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Write the settings file with the model
- settingsData := `{"model":"custom:Gemini-2.5-Pro-0","reasoningEffort":"none"}`
- if err := os.WriteFile(settingsPath, []byte(settingsData), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- model := ExtractModelFromTranscript(transcriptPath)
- if model != "Gemini-2.5-Pro-0" {
- t.Errorf("ExtractModelFromTranscript() = %q, want %q", model, "Gemini-2.5-Pro-0")
- }
-}
-
-func TestExtractModelFromTranscript_NoCustomPrefix(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/session.jsonl"
- settingsPath := tmpDir + "/session.settings.json"
-
- if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- settingsData := `{"model":"claude-opus-4-6"}`
- if err := os.WriteFile(settingsPath, []byte(settingsData), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- model := ExtractModelFromTranscript(transcriptPath)
- if model != "claude-opus-4-6" {
- t.Errorf("ExtractModelFromTranscript() = %q, want %q", model, "claude-opus-4-6")
- }
-}
-
-func TestExtractModelFromTranscript_NoSettingsFile(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/session.jsonl"
-
- // Write transcript but no settings file
- if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- model := ExtractModelFromTranscript(transcriptPath)
- if model != "" {
- t.Errorf("ExtractModelFromTranscript() = %q, want empty", model)
- }
-}
-
-func TestExtractModelFromTranscript_CorruptSettingsFile(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := tmpDir + "/session.jsonl"
- settingsPath := tmpDir + "/session.settings.json"
-
- if err := os.WriteFile(transcriptPath, []byte(`{"type":"session_start"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Write invalid JSON to the settings file
- if err := os.WriteFile(settingsPath, []byte(`{not valid json`), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- model := ExtractModelFromTranscript(transcriptPath)
- if model != "" {
- t.Errorf("ExtractModelFromTranscript() = %q, want empty for corrupt settings", model)
- }
-}
-
-func TestExtractModelFromTranscript_EmptyPath(t *testing.T) {
- t.Parallel()
-
- model := ExtractModelFromTranscript("")
- if model != "" {
- t.Errorf("ExtractModelFromTranscript(\"\") = %q, want empty", model)
- }
-}
diff --git a/cli/attach_2_test.go b/cli/attach_2_test.go
new file mode 100644
index 0000000..4fa2cda
--- /dev/null
+++ b/cli/attach_2_test.go
@@ -0,0 +1,381 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode" // register agent
+ _ "github.com/GrayCodeAI/trace/cli/agent/codex" // register agent
+ _ "github.com/GrayCodeAI/trace/cli/agent/cursor" // register agent
+ _ "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid" // register agent
+ _ "github.com/GrayCodeAI/trace/cli/agent/geminicli" // register agent
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+func TestAttach_CursorSuccess(t *testing.T) {
+ setupAttachTestRepo(t)
+
+ cursorDir := t.TempDir()
+ t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)
+
+ sessionID := "test-attach-cursor-session"
+ // Cursor uses JSONL format, same as Claude Code
+ transcriptContent := `{"type":"user","message":{"role":"user","content":"add dark mode"},"uuid":"u1"}
+{"type":"assistant","message":{"role":"assistant","content":"I'll add dark mode support."},"uuid":"a1"}
+`
+ // Cursor flat layout:
/.jsonl
+ if err := os.WriteFile(filepath.Join(cursorDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
+ if err != nil {
+ t.Fatalf("runAttach failed: %v", err)
+ }
+
+ if !strings.Contains(out.String(), "Attached session") {
+ t.Errorf("expected 'Attached session' in output, got: %s", out.String())
+ }
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ state, err := store.Load(context.Background(), sessionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state == nil {
+ t.Fatal("expected session state to be created")
+ return
+ }
+ if state.AgentType != agent.AgentTypeCursor {
+ t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCursor)
+ }
+ if state.SessionTurnCount != 1 {
+ t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
+ }
+}
+
+func TestAttach_CodexSuccess(t *testing.T) {
+ setupAttachTestRepo(t)
+
+ codexDir := t.TempDir()
+ t.Setenv("TRACE_TEST_CODEX_SESSION_DIR", codexDir)
+
+ sessionID := "019d6c43-1537-7343-9691-1f8cee04fe59"
+ transcriptContent := `{"timestamp":"2026-04-08T10:43:48.000Z","type":"session_meta","payload":{"id":"019d6c43-1537-7343-9691-1f8cee04fe59","timestamp":"2026-04-08T10:43:48.000Z"}}
+{"timestamp":"2026-04-08T10:43:49.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"investigate attach failure"}]}}
+{"timestamp":"2026-04-08T10:43:50.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Looking into it."}]}}
+`
+ sessionFile := filepath.Join(codexDir, "2026", "04", "08", "rollout-2026-04-08T10-43-48-"+sessionID+".jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(sessionFile, []byte(transcriptContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCodex, true)
+ if err != nil {
+ t.Fatalf("runAttach failed: %v", err)
+ }
+
+ if !strings.Contains(out.String(), "Attached session") {
+ t.Errorf("expected 'Attached session' in output, got: %s", out.String())
+ }
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ state, err := store.Load(context.Background(), sessionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state == nil {
+ t.Fatal("expected session state to be created")
+ return
+ }
+ if state.AgentType != agent.AgentTypeCodex {
+ t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCodex)
+ }
+ if state.TranscriptPath != sessionFile {
+ t.Errorf("TranscriptPath = %q, want %q", state.TranscriptPath, sessionFile)
+ }
+ if state.LastCheckpointID.IsEmpty() {
+ t.Error("expected LastCheckpointID to be set after attach")
+ }
+}
+
+func TestAttach_FactoryAIDroidSuccess(t *testing.T) {
+ setupAttachTestRepo(t)
+
+ droidDir := t.TempDir()
+ t.Setenv("TRACE_TEST_DROID_PROJECT_DIR", droidDir)
+
+ sessionID := "test-attach-droid-session"
+ // Factory AI Droid uses JSONL format
+ transcriptContent := `{"type":"user","message":{"role":"user","content":"deploy to staging"},"uuid":"u1"}
+{"type":"assistant","message":{"role":"assistant","content":"Deploying to staging now."},"uuid":"a1"}
+`
+ // Factory AI Droid: flat /.jsonl
+ if err := os.WriteFile(filepath.Join(droidDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ err := runAttach(context.Background(), &out, sessionID, agent.AgentNameFactoryAIDroid, true)
+ if err != nil {
+ t.Fatalf("runAttach failed: %v", err)
+ }
+
+ if !strings.Contains(out.String(), "Attached session") {
+ t.Errorf("expected 'Attached session' in output, got: %s", out.String())
+ }
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ state, err := store.Load(context.Background(), sessionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state == nil {
+ t.Fatal("expected session state to be created")
+ return
+ }
+ if state.AgentType != agent.AgentTypeFactoryAIDroid {
+ t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeFactoryAIDroid)
+ }
+ if state.SessionTurnCount != 1 {
+ t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
+ }
+}
+
+func TestAttach_CursorNestedLayout(t *testing.T) {
+ setupAttachTestRepo(t)
+
+ cursorDir := t.TempDir()
+ t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)
+
+ sessionID := "test-cursor-nested-layout"
+ transcriptContent := `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"}
+`
+ // Cursor IDE nested layout: //.jsonl
+ nestedDir := filepath.Join(cursorDir, sessionID)
+ if err := os.MkdirAll(nestedDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nestedDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
+ if err != nil {
+ t.Fatalf("runAttach failed: %v", err)
+ }
+
+ if !strings.Contains(out.String(), "Attached session") {
+ t.Errorf("expected 'Attached session' in output, got: %s", out.String())
+ }
+}
+
+// setupAttachTestRepo creates a temp git repo with one commit and enables Trace.
+// Returns the repo directory. Caller must not use t.Parallel() (uses t.Chdir).
+func setupAttachTestRepo(t *testing.T) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "init.txt", "init")
+ testutil.GitAdd(t, tmpDir, "init.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+ t.Chdir(tmpDir)
+ enableTrace(t, tmpDir)
+}
+
+// setupClaudeTranscript creates a fake Claude transcript file.
+// The file's mtime is backdated so that waitForTranscriptFlush treats it as
+// stale and skips the 3-second poll loop.
+func setupClaudeTranscript(t *testing.T, sessionID, content string) {
+ t.Helper()
+ claudeDir := t.TempDir()
+ t.Setenv("TRACE_TEST_CLAUDE_PROJECT_DIR", claudeDir)
+ fpath := filepath.Join(claudeDir, sessionID+".jsonl")
+ if err := os.WriteFile(fpath, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stale := time.Now().Add(-3 * time.Minute)
+ if err := os.Chtimes(fpath, stale, stale); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// enableTrace creates the .trace/settings.json file to mark Trace as enabled.
+func enableTrace(t *testing.T, repoDir string) {
+ t.Helper()
+ traceDir := filepath.Join(repoDir, ".trace")
+ if err := os.MkdirAll(traceDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ settingsContent := `{"enabled": true}`
+ if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func setAttachCheckpointsV2Enabled(t *testing.T, repoDir string) {
+ t.Helper()
+ traceDir := filepath.Join(repoDir, ".trace")
+ if err := os.MkdirAll(traceDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`
+ if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func setAttachCheckpointsV2Only(t *testing.T, repoDir string) {
+ t.Helper()
+ traceDir := filepath.Join(repoDir, ".trace")
+ if err := os.MkdirAll(traceDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_version": 2}}`
+ if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func mustGetwd(t *testing.T) string {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func readFileFromRef(t *testing.T, repo *git.Repository, refName, filePath string) (string, bool) {
+ t.Helper()
+
+ ref, err := repo.Reference(plumbing.ReferenceName(refName), true)
+ if err != nil {
+ return "", false
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return "", false
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ return "", false
+ }
+ file, err := tree.File(filePath)
+ if err != nil {
+ return "", false
+ }
+ content, err := file.Contents()
+ if err != nil {
+ return "", false
+ }
+ return content, true
+}
+
+// TestAttach_DiscoversExternalAgents verifies that `trace attach --agent `
+// gets past the agent registry check when external_agents is enabled and a
+// matching binary is on PATH. Without the DiscoverAndRegister call in the
+// attach command, this would fail with "unknown agent: ".
+//
+// This test does not verify end-to-end attach behavior — it asserts only
+// that discovery ran. The command is expected to fail later (transcript
+// resolution) because we don't stand up a real session.
+func TestAttach_DiscoversExternalAgents(t *testing.T) {
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ setupAttachTestRepo(t)
+
+ // Overwrite settings to enable external_agents (enableTrace writes the
+ // file without it).
+ cwd := mustGetwd(t)
+ settingsPath := filepath.Join(cwd, ".trace", "settings.json")
+ if err := os.WriteFile(settingsPath, []byte(`{"enabled":true,"external_agents":true}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // Use a unique name so concurrent test runs can't collide in the global
+ // agent registry.
+ agentName := types.AgentName("attachtest-discovery-agent")
+
+ binDir := t.TempDir()
+ binPath := filepath.Join(binDir, "trace-agent-"+string(agentName))
+ infoJSON := `{
+ "protocol_version": 1,
+ "name": "` + string(agentName) + `",
+ "type": "Attach Test Agent",
+ "description": "Agent for attach discovery test",
+ "is_preview": false,
+ "protected_dirs": [],
+ "hook_names": [],
+ "capabilities": {}
+}`
+ script := "#!/bin/sh\nif [ \"$1\" = \"info\" ]; then\n echo '" + infoJSON + "'\nfi\n"
+ if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
+ t.Fatalf("failed to write mock agent binary: %v", err)
+ }
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cmd := newAttachCmd()
+ // Pass a bogus session ID — the point is to exercise the registry check,
+ // not full attach flow.
+ cmd.SetArgs([]string{"--agent", string(agentName), "-f", "fake-session-id"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ err := cmd.Execute()
+ // We expect an error (no transcript), but it must not be the
+ // registry-lookup error. A regression (removing DiscoverAndRegister)
+ // would produce "unknown agent: attachtest-discovery-agent".
+ if err == nil {
+ t.Fatalf("expected attach to fail on missing transcript, got success\noutput: %s", out.String())
+ }
+ if strings.Contains(err.Error(), "unknown agent") {
+ t.Fatalf("attach did not discover external agent — got registry miss: %v", err)
+ }
+
+ // Also confirm the agent actually landed in the registry, so the check
+ // above is meaningful (not merely passing because some other error
+ // short-circuited before the registry lookup).
+ if _, lookupErr := agent.Get(agentName); lookupErr != nil {
+ t.Errorf("expected external agent %q in registry after attach, got: %v", agentName, lookupErr)
+ }
+}
+
+func runGitInDir(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.CommandContext(context.Background(), "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)
+ }
+}
diff --git a/cli/attach_test.go b/cli/attach_test.go
index c62f496..61689e4 100644
--- a/cli/attach_test.go
+++ b/cli/attach_test.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"os"
- "os/exec"
"path/filepath"
"regexp"
"strings"
@@ -17,12 +16,10 @@ import (
_ "github.com/GrayCodeAI/trace/cli/agent/cursor" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/geminicli" // register agent
- "github.com/GrayCodeAI/trace/cli/agent/types"
cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
- "github.com/GrayCodeAI/trace/cli/testutil"
"github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/redact"
@@ -790,359 +787,3 @@ func TestAttach_GeminiSuccess(t *testing.T) {
t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
}
}
-
-func TestAttach_CursorSuccess(t *testing.T) {
- setupAttachTestRepo(t)
-
- cursorDir := t.TempDir()
- t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)
-
- sessionID := "test-attach-cursor-session"
- // Cursor uses JSONL format, same as Claude Code
- transcriptContent := `{"type":"user","message":{"role":"user","content":"add dark mode"},"uuid":"u1"}
-{"type":"assistant","message":{"role":"assistant","content":"I'll add dark mode support."},"uuid":"a1"}
-`
- // Cursor flat layout: /.jsonl
- if err := os.WriteFile(filepath.Join(cursorDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
- t.Fatal(err)
- }
-
- var out bytes.Buffer
- err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
- if err != nil {
- t.Fatalf("runAttach failed: %v", err)
- }
-
- if !strings.Contains(out.String(), "Attached session") {
- t.Errorf("expected 'Attached session' in output, got: %s", out.String())
- }
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- state, err := store.Load(context.Background(), sessionID)
- if err != nil {
- t.Fatal(err)
- }
- if state == nil {
- t.Fatal("expected session state to be created")
- return
- }
- if state.AgentType != agent.AgentTypeCursor {
- t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCursor)
- }
- if state.SessionTurnCount != 1 {
- t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
- }
-}
-
-func TestAttach_CodexSuccess(t *testing.T) {
- setupAttachTestRepo(t)
-
- codexDir := t.TempDir()
- t.Setenv("TRACE_TEST_CODEX_SESSION_DIR", codexDir)
-
- sessionID := "019d6c43-1537-7343-9691-1f8cee04fe59"
- transcriptContent := `{"timestamp":"2026-04-08T10:43:48.000Z","type":"session_meta","payload":{"id":"019d6c43-1537-7343-9691-1f8cee04fe59","timestamp":"2026-04-08T10:43:48.000Z"}}
-{"timestamp":"2026-04-08T10:43:49.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"investigate attach failure"}]}}
-{"timestamp":"2026-04-08T10:43:50.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Looking into it."}]}}
-`
- sessionFile := filepath.Join(codexDir, "2026", "04", "08", "rollout-2026-04-08T10-43-48-"+sessionID+".jsonl")
- if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(sessionFile, []byte(transcriptContent), 0o600); err != nil {
- t.Fatal(err)
- }
-
- var out bytes.Buffer
- err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCodex, true)
- if err != nil {
- t.Fatalf("runAttach failed: %v", err)
- }
-
- if !strings.Contains(out.String(), "Attached session") {
- t.Errorf("expected 'Attached session' in output, got: %s", out.String())
- }
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- state, err := store.Load(context.Background(), sessionID)
- if err != nil {
- t.Fatal(err)
- }
- if state == nil {
- t.Fatal("expected session state to be created")
- return
- }
- if state.AgentType != agent.AgentTypeCodex {
- t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCodex)
- }
- if state.TranscriptPath != sessionFile {
- t.Errorf("TranscriptPath = %q, want %q", state.TranscriptPath, sessionFile)
- }
- if state.LastCheckpointID.IsEmpty() {
- t.Error("expected LastCheckpointID to be set after attach")
- }
-}
-
-func TestAttach_FactoryAIDroidSuccess(t *testing.T) {
- setupAttachTestRepo(t)
-
- droidDir := t.TempDir()
- t.Setenv("TRACE_TEST_DROID_PROJECT_DIR", droidDir)
-
- sessionID := "test-attach-droid-session"
- // Factory AI Droid uses JSONL format
- transcriptContent := `{"type":"user","message":{"role":"user","content":"deploy to staging"},"uuid":"u1"}
-{"type":"assistant","message":{"role":"assistant","content":"Deploying to staging now."},"uuid":"a1"}
-`
- // Factory AI Droid: flat /.jsonl
- if err := os.WriteFile(filepath.Join(droidDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
- t.Fatal(err)
- }
-
- var out bytes.Buffer
- err := runAttach(context.Background(), &out, sessionID, agent.AgentNameFactoryAIDroid, true)
- if err != nil {
- t.Fatalf("runAttach failed: %v", err)
- }
-
- if !strings.Contains(out.String(), "Attached session") {
- t.Errorf("expected 'Attached session' in output, got: %s", out.String())
- }
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- state, err := store.Load(context.Background(), sessionID)
- if err != nil {
- t.Fatal(err)
- }
- if state == nil {
- t.Fatal("expected session state to be created")
- return
- }
- if state.AgentType != agent.AgentTypeFactoryAIDroid {
- t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeFactoryAIDroid)
- }
- if state.SessionTurnCount != 1 {
- t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
- }
-}
-
-func TestAttach_CursorNestedLayout(t *testing.T) {
- setupAttachTestRepo(t)
-
- cursorDir := t.TempDir()
- t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)
-
- sessionID := "test-cursor-nested-layout"
- transcriptContent := `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"}
-`
- // Cursor IDE nested layout: //.jsonl
- nestedDir := filepath.Join(cursorDir, sessionID)
- if err := os.MkdirAll(nestedDir, 0o750); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(nestedDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
- t.Fatal(err)
- }
-
- var out bytes.Buffer
- err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
- if err != nil {
- t.Fatalf("runAttach failed: %v", err)
- }
-
- if !strings.Contains(out.String(), "Attached session") {
- t.Errorf("expected 'Attached session' in output, got: %s", out.String())
- }
-}
-
-// setupAttachTestRepo creates a temp git repo with one commit and enables Trace.
-// Returns the repo directory. Caller must not use t.Parallel() (uses t.Chdir).
-func setupAttachTestRepo(t *testing.T) {
- t.Helper()
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "init.txt", "init")
- testutil.GitAdd(t, tmpDir, "init.txt")
- testutil.GitCommit(t, tmpDir, "init")
- t.Chdir(tmpDir)
- enableTrace(t, tmpDir)
-}
-
-// setupClaudeTranscript creates a fake Claude transcript file.
-// The file's mtime is backdated so that waitForTranscriptFlush treats it as
-// stale and skips the 3-second poll loop.
-func setupClaudeTranscript(t *testing.T, sessionID, content string) {
- t.Helper()
- claudeDir := t.TempDir()
- t.Setenv("TRACE_TEST_CLAUDE_PROJECT_DIR", claudeDir)
- fpath := filepath.Join(claudeDir, sessionID+".jsonl")
- if err := os.WriteFile(fpath, []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- stale := time.Now().Add(-3 * time.Minute)
- if err := os.Chtimes(fpath, stale, stale); err != nil {
- t.Fatal(err)
- }
-}
-
-// enableTrace creates the .trace/settings.json file to mark Trace as enabled.
-func enableTrace(t *testing.T, repoDir string) {
- t.Helper()
- traceDir := filepath.Join(repoDir, ".trace")
- if err := os.MkdirAll(traceDir, 0o750); err != nil {
- t.Fatal(err)
- }
- settingsContent := `{"enabled": true}`
- if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
- t.Fatal(err)
- }
-}
-
-func setAttachCheckpointsV2Enabled(t *testing.T, repoDir string) {
- t.Helper()
- traceDir := filepath.Join(repoDir, ".trace")
- if err := os.MkdirAll(traceDir, 0o750); err != nil {
- t.Fatal(err)
- }
- settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`
- if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
- t.Fatal(err)
- }
-}
-
-func setAttachCheckpointsV2Only(t *testing.T, repoDir string) {
- t.Helper()
- traceDir := filepath.Join(repoDir, ".trace")
- if err := os.MkdirAll(traceDir, 0o750); err != nil {
- t.Fatal(err)
- }
- settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_version": 2}}`
- if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
- t.Fatal(err)
- }
-}
-
-func mustGetwd(t *testing.T) string {
- t.Helper()
- dir, err := os.Getwd()
- if err != nil {
- t.Fatal(err)
- }
- return dir
-}
-
-func readFileFromRef(t *testing.T, repo *git.Repository, refName, filePath string) (string, bool) {
- t.Helper()
-
- ref, err := repo.Reference(plumbing.ReferenceName(refName), true)
- if err != nil {
- return "", false
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return "", false
- }
- tree, err := commit.Tree()
- if err != nil {
- return "", false
- }
- file, err := tree.File(filePath)
- if err != nil {
- return "", false
- }
- content, err := file.Contents()
- if err != nil {
- return "", false
- }
- return content, true
-}
-
-// TestAttach_DiscoversExternalAgents verifies that `trace attach --agent `
-// gets past the agent registry check when external_agents is enabled and a
-// matching binary is on PATH. Without the DiscoverAndRegister call in the
-// attach command, this would fail with "unknown agent: ".
-//
-// This test does not verify end-to-end attach behavior — it asserts only
-// that discovery ran. The command is expected to fail later (transcript
-// resolution) because we don't stand up a real session.
-func TestAttach_DiscoversExternalAgents(t *testing.T) {
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- setupAttachTestRepo(t)
-
- // Overwrite settings to enable external_agents (enableTrace writes the
- // file without it).
- cwd := mustGetwd(t)
- settingsPath := filepath.Join(cwd, ".trace", "settings.json")
- if err := os.WriteFile(settingsPath, []byte(`{"enabled":true,"external_agents":true}`), 0o600); err != nil {
- t.Fatal(err)
- }
-
- // Use a unique name so concurrent test runs can't collide in the global
- // agent registry.
- agentName := types.AgentName("attachtest-discovery-agent")
-
- binDir := t.TempDir()
- binPath := filepath.Join(binDir, "trace-agent-"+string(agentName))
- infoJSON := `{
- "protocol_version": 1,
- "name": "` + string(agentName) + `",
- "type": "Attach Test Agent",
- "description": "Agent for attach discovery test",
- "is_preview": false,
- "protected_dirs": [],
- "hook_names": [],
- "capabilities": {}
-}`
- script := "#!/bin/sh\nif [ \"$1\" = \"info\" ]; then\n echo '" + infoJSON + "'\nfi\n"
- if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
- t.Fatalf("failed to write mock agent binary: %v", err)
- }
- t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- cmd := newAttachCmd()
- // Pass a bogus session ID — the point is to exercise the registry check,
- // not full attach flow.
- cmd.SetArgs([]string{"--agent", string(agentName), "-f", "fake-session-id"})
- var out bytes.Buffer
- cmd.SetOut(&out)
- cmd.SetErr(&out)
-
- err := cmd.Execute()
- // We expect an error (no transcript), but it must not be the
- // registry-lookup error. A regression (removing DiscoverAndRegister)
- // would produce "unknown agent: attachtest-discovery-agent".
- if err == nil {
- t.Fatalf("expected attach to fail on missing transcript, got success\noutput: %s", out.String())
- }
- if strings.Contains(err.Error(), "unknown agent") {
- t.Fatalf("attach did not discover external agent — got registry miss: %v", err)
- }
-
- // Also confirm the agent actually landed in the registry, so the check
- // above is meaningful (not merely passing because some other error
- // short-circuited before the registry lookup).
- if _, lookupErr := agent.Get(agentName); lookupErr != nil {
- t.Errorf("expected external agent %q in registry after attach, got: %v", agentName, lookupErr)
- }
-}
-
-func runGitInDir(t *testing.T, dir string, args ...string) {
- t.Helper()
- cmd := exec.CommandContext(context.Background(), "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)
- }
-}
diff --git a/cli/checkpoint/checkpoint_2_test.go b/cli/checkpoint/checkpoint_2_test.go
new file mode 100644
index 0000000..8da14f2
--- /dev/null
+++ b/cli/checkpoint/checkpoint_2_test.go
@@ -0,0 +1,785 @@
+package checkpoint
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/config"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestUpdateSummary_NotFound verifies that UpdateSummary returns an error
+// when the checkpoint doesn't exist.
+func TestUpdateSummary_NotFound(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+
+ // Ensure sessions branch exists
+ err := store.ensureSessionsBranch(context.Background())
+ if err != nil {
+ t.Fatalf("ensureSessionsBranch() error = %v", err)
+ }
+
+ // Try to update a non-existent checkpoint (ID must be 12 hex chars)
+ checkpointID := id.MustCheckpointID("000000000000")
+ summary := &Summary{Intent: "Test", Outcome: "Test"}
+
+ err = store.UpdateSummary(context.Background(), checkpointID, summary)
+ if err == nil {
+ t.Error("UpdateSummary() should return error for non-existent checkpoint")
+ }
+ if !errors.Is(err, ErrCheckpointNotFound) {
+ t.Errorf("UpdateSummary() error = %v, want ErrCheckpointNotFound", err)
+ }
+}
+
+// TestListCommitted_FallsBackToRemote verifies that ListCommitted can find
+// checkpoints when only origin/trace/checkpoints/v1 exists (simulating post-clone state).
+func TestListCommitted_FallsBackToRemote(t *testing.T) {
+ // Create "remote" repo (non-bare, so we can make commits)
+ remoteDir := t.TempDir()
+ remoteRepo, err := git.PlainInit(remoteDir, false)
+ if err != nil {
+ t.Fatalf("failed to init remote repo: %v", err)
+ }
+
+ // Create an initial commit on main branch (required for cloning)
+ remoteWorktree, err := remoteRepo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get remote worktree: %v", err)
+ }
+ readmeFile := filepath.Join(remoteDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := remoteWorktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ if _, err := remoteWorktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create trace/checkpoints/v1 branch on the remote with a checkpoint
+ remoteStore := NewGitStore(remoteRepo)
+ cpID := id.MustCheckpointID("abcdef123456")
+ err = remoteStore.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session-id",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"test": true}`)),
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("failed to write checkpoint to remote: %v", err)
+ }
+
+ // Clone the repo (this clones main, but not trace/checkpoints/v1 by default)
+ localDir := t.TempDir()
+ localRepo, err := git.PlainClone(localDir, &git.CloneOptions{
+ URL: remoteDir,
+ })
+ if err != nil {
+ t.Fatalf("failed to clone repo: %v", err)
+ }
+
+ // Fetch the trace/checkpoints/v1 branch to origin/trace/checkpoints/v1
+ // (but don't create local branch - simulating post-clone state)
+ refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", paths.MetadataBranchName, paths.MetadataBranchName)
+ err = localRepo.Fetch(&git.FetchOptions{
+ RemoteName: "origin",
+ RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
+ })
+ if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
+ t.Fatalf("failed to fetch trace/checkpoints/v1: %v", err)
+ }
+
+ // Verify local branch doesn't exist
+ _, err = localRepo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err == nil {
+ t.Fatal("local trace/checkpoints/v1 branch should not exist")
+ }
+
+ // Verify remote-tracking branch exists
+ _, err = localRepo.Reference(plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("origin/trace/checkpoints/v1 should exist: %v", err)
+ }
+
+ // ListCommitted should find the checkpoint by falling back to remote
+ localStore := NewGitStore(localRepo)
+ checkpoints, err := localStore.ListCommitted(context.Background())
+ if err != nil {
+ t.Fatalf("ListCommitted() error = %v", err)
+ }
+ if len(checkpoints) != 1 {
+ t.Errorf("ListCommitted() returned %d checkpoints, want 1", len(checkpoints))
+ }
+ if len(checkpoints) > 0 && checkpoints[0].CheckpointID.String() != cpID.String() {
+ t.Errorf("ListCommitted() checkpoint ID = %q, want %q", checkpoints[0].CheckpointID, cpID)
+ }
+}
+
+// TestGetCheckpointAuthor verifies that GetCheckpointAuthor retrieves the
+// author of the commit that created the checkpoint on the trace/checkpoints/v1 branch.
+func TestGetCheckpointAuthor(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
+
+ // Create a checkpoint with specific author info
+ authorName := "Alice Developer"
+ authorEmail := "alice@example.com"
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "test-session-author",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("test transcript")),
+ FilesTouched: []string{"main.go"},
+ AuthorName: authorName,
+ AuthorEmail: authorEmail,
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Retrieve the author
+ author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("GetCheckpointAuthor() error = %v", err)
+ }
+
+ if author.Name != authorName {
+ t.Errorf("author.Name = %q, want %q", author.Name, authorName)
+ }
+ if author.Email != authorEmail {
+ t.Errorf("author.Email = %q, want %q", author.Email, authorEmail)
+ }
+}
+
+// TestGetCheckpointAuthor_NotFound verifies that GetCheckpointAuthor returns
+// empty author when the checkpoint doesn't exist.
+func TestGetCheckpointAuthor_NotFound(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+
+ // Query for a non-existent checkpoint (must be valid hex)
+ checkpointID := id.MustCheckpointID("ffffffffffff")
+
+ author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("GetCheckpointAuthor() error = %v", err)
+ }
+
+ // Should return empty author (no error)
+ if author.Name != "" || author.Email != "" {
+ t.Errorf("expected empty author for non-existent checkpoint, got Name=%q, Email=%q", author.Name, author.Email)
+ }
+}
+
+// TestGetCheckpointAuthor_NoSessionsBranch verifies that GetCheckpointAuthor
+// returns empty author when the trace/checkpoints/v1 branch doesn't exist.
+func TestGetCheckpointAuthor_NoSessionsBranch(t *testing.T) {
+ // Create a fresh repo without sessions branch
+ tempDir := t.TempDir()
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeeff")
+
+ author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("GetCheckpointAuthor() error = %v", err)
+ }
+
+ // Should return empty author (no error)
+ if author.Name != "" || author.Email != "" {
+ t.Errorf("expected empty author when sessions branch doesn't exist, got Name=%q, Email=%q", author.Name, author.Email)
+ }
+}
+
+// =============================================================================
+// Multi-Session Tests - Tests for checkpoint structure with CheckpointSummary
+// at root level and sessions stored in numbered subfolders (0-based: 0/, 1/, 2/)
+// =============================================================================
+
+// TestWriteCommitted_MultipleSessionsSameCheckpoint verifies that writing multiple
+// sessions to the same checkpoint ID creates separate numbered subdirectories.
+func TestWriteCommitted_MultipleSessionsSameCheckpoint(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("a1a2a3a4a5a6")
+
+ // Write first session
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-one",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "first session"}`)),
+ Prompts: []string{"First prompt"},
+ FilesTouched: []string{"file1.go"},
+ CheckpointsCount: 3,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() first session error = %v", err)
+ }
+
+ // Write second session to the same checkpoint ID
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-two",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "second session"}`)),
+ Prompts: []string{"Second prompt"},
+ FilesTouched: []string{"file2.go"},
+ CheckpointsCount: 2,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() second session error = %v", err)
+ }
+
+ // Read the checkpoint summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+ if summary == nil {
+ t.Fatal("ReadCommitted() returned nil summary")
+ return
+ }
+
+ // Verify Sessions array has 2 entries
+ if len(summary.Sessions) != 2 {
+ t.Errorf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
+ }
+
+ // Verify both sessions have correct file paths (0-based indexing)
+ if !strings.Contains(summary.Sessions[0].Transcript, "/0/") {
+ t.Errorf("session 0 transcript path should contain '/0/', got %s", summary.Sessions[0].Transcript)
+ }
+ if !strings.Contains(summary.Sessions[1].Transcript, "/1/") {
+ t.Errorf("session 1 transcript path should contain '/1/', got %s", summary.Sessions[1].Transcript)
+ }
+
+ // Verify session content can be read from each subdirectory
+ content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if content0.Metadata.SessionID != "session-one" {
+ t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-one")
+ }
+
+ content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(1) error = %v", err)
+ }
+ if content1.Metadata.SessionID != "session-two" {
+ t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-two")
+ }
+}
+
+// TestWriteCommitted_Aggregation verifies that CheckpointSummary correctly
+// aggregates statistics (CheckpointsCount, FilesTouched, TokenUsage) from
+// multiple sessions written to the same checkpoint.
+func TestWriteCommitted_Aggregation(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("b1b2b3b4b5b6")
+
+ // Write first session with specific stats
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-one",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "first"}`)),
+ FilesTouched: []string{"a.go", "b.go"},
+ CheckpointsCount: 3,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ APICallCount: 5,
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() first session error = %v", err)
+ }
+
+ // Write second session with overlapping and new files
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-two",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "second"}`)),
+ FilesTouched: []string{"b.go", "c.go"}, // b.go overlaps
+ CheckpointsCount: 2,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 50,
+ OutputTokens: 25,
+ APICallCount: 3,
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() second session error = %v", err)
+ }
+
+ // Read the checkpoint summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+ if summary == nil {
+ t.Fatal("ReadCommitted() returned nil summary")
+ return
+ }
+
+ // Verify aggregated CheckpointsCount = 3 + 2 = 5
+ if summary.CheckpointsCount != 5 {
+ t.Errorf("summary.CheckpointsCount = %d, want 5", summary.CheckpointsCount)
+ }
+
+ // Verify merged FilesTouched = ["a.go", "b.go", "c.go"] (sorted, deduplicated)
+ expectedFiles := []string{"a.go", "b.go", "c.go"}
+ if len(summary.FilesTouched) != len(expectedFiles) {
+ t.Errorf("len(summary.FilesTouched) = %d, want %d", len(summary.FilesTouched), len(expectedFiles))
+ }
+ for i, want := range expectedFiles {
+ if i >= len(summary.FilesTouched) {
+ break
+ }
+ if summary.FilesTouched[i] != want {
+ t.Errorf("summary.FilesTouched[%d] = %q, want %q", i, summary.FilesTouched[i], want)
+ }
+ }
+
+ // Verify aggregated TokenUsage
+ if summary.TokenUsage == nil {
+ t.Fatal("summary.TokenUsage should not be nil")
+ }
+ if summary.TokenUsage.InputTokens != 150 {
+ t.Errorf("summary.TokenUsage.InputTokens = %d, want 150", summary.TokenUsage.InputTokens)
+ }
+ if summary.TokenUsage.OutputTokens != 75 {
+ t.Errorf("summary.TokenUsage.OutputTokens = %d, want 75", summary.TokenUsage.OutputTokens)
+ }
+ if summary.TokenUsage.APICallCount != 8 {
+ t.Errorf("summary.TokenUsage.APICallCount = %d, want 8", summary.TokenUsage.APICallCount)
+ }
+}
+
+// TestReadCommitted_ReturnsCheckpointSummary verifies that ReadCommitted returns
+// a CheckpointSummary with the correct structure including Sessions array.
+func TestReadCommitted_ReturnsCheckpointSummary(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("c1c2c3c4c5c6")
+
+ // Write two sessions
+ for i, sessionID := range []string{"session-alpha", "session-beta"} {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: sessionID,
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session": %d}`, i))),
+ Prompts: []string{fmt.Sprintf("Prompt %d", i)},
+ FilesTouched: []string{fmt.Sprintf("file%d.go", i)},
+ CheckpointsCount: i + 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %d error = %v", i, err)
+ }
+ }
+
+ // Read the checkpoint summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+ if summary == nil {
+ t.Fatal("ReadCommitted() returned nil summary")
+ return
+ }
+
+ // Verify basic summary fields
+ if summary.CheckpointID != checkpointID {
+ t.Errorf("summary.CheckpointID = %v, want %v", summary.CheckpointID, checkpointID)
+ }
+ if summary.Strategy != "manual-commit" {
+ t.Errorf("summary.Strategy = %q, want %q", summary.Strategy, "manual-commit")
+ }
+
+ // Verify Sessions array
+ if len(summary.Sessions) != 2 {
+ t.Fatalf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
+ }
+
+ // Verify file paths point to correct locations
+ for i, session := range summary.Sessions {
+ expectedSubdir := fmt.Sprintf("/%d/", i)
+ if !strings.Contains(session.Metadata, expectedSubdir) {
+ t.Errorf("session %d Metadata path should contain %q, got %q", i, expectedSubdir, session.Metadata)
+ }
+ if !strings.Contains(session.Transcript, expectedSubdir) {
+ t.Errorf("session %d Transcript path should contain %q, got %q", i, expectedSubdir, session.Transcript)
+ }
+ }
+}
+
+// TestReadSessionContent_ByIndex verifies that ReadSessionContent can read
+// specific sessions by their 0-based index within a checkpoint.
+func TestReadSessionContent_ByIndex(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("d1d2d3d4d5d6")
+
+ // Write two sessions with distinct content
+ sessions := []struct {
+ id string
+ transcript string
+ prompt string
+ }{
+ {"session-first", `{"order": "first"}`, "First user prompt"},
+ {"session-second", `{"order": "second"}`, "Second user prompt"},
+ }
+
+ for _, s := range sessions {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: s.id,
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(s.transcript)),
+ Prompts: []string{s.prompt},
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %s error = %v", s.id, err)
+ }
+ }
+
+ // Read session 0
+ content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if content0.Metadata.SessionID != "session-first" {
+ t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-first")
+ }
+ if !strings.Contains(string(content0.Transcript), "first") {
+ t.Errorf("session 0 transcript should contain 'first', got %s", string(content0.Transcript))
+ }
+ if !strings.Contains(content0.Prompts, "First") {
+ t.Errorf("session 0 prompts should contain 'First', got %s", content0.Prompts)
+ }
+
+ // Read session 1
+ content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(1) error = %v", err)
+ }
+ if content1.Metadata.SessionID != "session-second" {
+ t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-second")
+ }
+ if !strings.Contains(string(content1.Transcript), "second") {
+ t.Errorf("session 1 transcript should contain 'second', got %s", string(content1.Transcript))
+ }
+}
+
+// writeSingleSession is a test helper that creates a store with a single session
+// and returns the store and checkpoint ID for further testing.
+func writeSingleSession(t *testing.T, cpIDStr, sessionID, transcript string) (*GitStore, id.CheckpointID) {
+ t.Helper()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID(cpIDStr)
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: sessionID,
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(transcript)),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+ return store, checkpointID
+}
+
+func TestWriteCommitted_CodexSanitizesPortableTranscript(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("c0de1234beef")
+
+ transcript := `{"timestamp":"2026-03-25T11:31:11.754Z","type":"response_item","payload":{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"}}
+{"timestamp":"2026-03-25T11:31:11.755Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"REDACTED"}}
+{"timestamp":"2026-03-25T11:31:11.756Z","type":"compacted","payload":{"message":"","replacement_history":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"reasoning","summary":[{"text":"nested"}],"encrypted_content":"REDACTED"},{"type":"compaction","encrypted_content":"REDACTED"},{"type":"compaction_summary","encrypted_content":"REDACTED"}]}}
+`
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "codex-session",
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeCodex,
+ Transcript: redact.AlreadyRedacted([]byte(transcript)),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ require.NoError(t, err)
+
+ content, err := store.ReadLatestSessionContent(context.Background(), checkpointID)
+ require.NoError(t, err)
+
+ got := string(content.Transcript)
+ require.NotContains(t, got, `"encrypted_content":"REDACTED"`)
+ require.NotContains(t, got, `"type":"compaction"`)
+ require.NotContains(t, got, `"type":"compaction_summary"`)
+ require.Contains(t, got, `"summary":[{"text":"brief"}]`)
+ require.Contains(t, got, `"summary":[{"text":"nested"}]`)
+}
+
+// TestReadSessionContent_InvalidIndex verifies that ReadSessionContent returns
+// an error when requesting a session index that doesn't exist.
+func TestReadSessionContent_InvalidIndex(t *testing.T) {
+ store, checkpointID := writeSingleSession(t, "e1e2e3e4e5e6", "only-session", `{"single": true}`)
+
+ // Try to read session index 1 (doesn't exist)
+ _, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
+ if err == nil {
+ t.Error("ReadSessionContent(1) should return error for non-existent session")
+ }
+ if !strings.Contains(err.Error(), "session 1 not found") {
+ t.Errorf("error should mention session not found, got: %v", err)
+ }
+ if !errors.Is(err, ErrCheckpointNotFound) {
+ t.Errorf("ReadSessionContent(1) error = %v, want ErrCheckpointNotFound", err)
+ }
+}
+
+// TestReadLatestSessionContent verifies that ReadLatestSessionContent returns
+// the content of the most recently added session (highest index).
+func TestReadLatestSessionContent(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("f1f2f3f4f5f6")
+
+ // Write three sessions
+ for i := range 3 {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: fmt.Sprintf("session-%d", i),
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"index": %d}`, i))),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %d error = %v", i, err)
+ }
+ }
+
+ // Read latest session content
+ content, err := store.ReadLatestSessionContent(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadLatestSessionContent() error = %v", err)
+ }
+
+ // Should return session 2 (0-indexed, so latest is index 2)
+ if content.Metadata.SessionID != "session-2" {
+ t.Errorf("latest session SessionID = %q, want %q", content.Metadata.SessionID, "session-2")
+ }
+ if !strings.Contains(string(content.Transcript), `"index": 2`) {
+ t.Errorf("latest session transcript should contain index 2, got %s", string(content.Transcript))
+ }
+}
+
+// TestReadSessionContentByID verifies that ReadSessionContentByID can find
+// a session by its session ID rather than by index.
+func TestReadSessionContentByID(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("010203040506")
+
+ // Write two sessions with distinct IDs
+ sessionIDs := []string{"unique-id-alpha", "unique-id-beta"}
+ for i, sid := range sessionIDs {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: sid,
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session_name": "%s"}`, sid))),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %d error = %v", i, err)
+ }
+ }
+
+ // Read by session ID
+ content, err := store.ReadSessionContentByID(context.Background(), checkpointID, "unique-id-beta")
+ if err != nil {
+ t.Fatalf("ReadSessionContentByID() error = %v", err)
+ }
+
+ if content.Metadata.SessionID != "unique-id-beta" {
+ t.Errorf("SessionID = %q, want %q", content.Metadata.SessionID, "unique-id-beta")
+ }
+ if !strings.Contains(string(content.Transcript), "unique-id-beta") {
+ t.Errorf("transcript should contain session name, got %s", string(content.Transcript))
+ }
+}
+
+// TestReadSessionContentByID_NotFound verifies that ReadSessionContentByID
+// returns an error when the session ID doesn't exist in the checkpoint.
+func TestReadSessionContentByID_NotFound(t *testing.T) {
+ store, checkpointID := writeSingleSession(t, "111213141516", "existing-session", `{"exists": true}`)
+
+ // Try to read non-existent session ID
+ _, err := store.ReadSessionContentByID(context.Background(), checkpointID, "nonexistent-session")
+ if err == nil {
+ t.Error("ReadSessionContentByID() should return error for non-existent session ID")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Errorf("error should mention 'not found', got: %v", err)
+ }
+}
+
+// TestListCommitted_MultiSessionInfo verifies that ListCommitted returns correct
+// information for checkpoints with multiple sessions.
+func TestListCommitted_MultiSessionInfo(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("212223242526")
+
+ // Write two sessions to the same checkpoint
+ for i, sid := range []string{"list-session-1", "list-session-2"} {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: sid,
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"i": %d}`, i))),
+ FilesTouched: []string{fmt.Sprintf("file%d.go", i)},
+ CheckpointsCount: i + 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %d error = %v", i, err)
+ }
+ }
+
+ // List all checkpoints
+ checkpoints, err := store.ListCommitted(context.Background())
+ if err != nil {
+ t.Fatalf("ListCommitted() error = %v", err)
+ }
+
+ // Find our checkpoint
+ var found *CommittedInfo
+ for i := range checkpoints {
+ if checkpoints[i].CheckpointID == checkpointID {
+ found = &checkpoints[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("checkpoint not found in ListCommitted() results")
+ return
+ }
+
+ // Verify SessionCount = 2
+ if found.SessionCount != 2 {
+ t.Errorf("SessionCount = %d, want 2", found.SessionCount)
+ }
+
+ // Verify SessionID is from the latest session
+ if found.SessionID != "list-session-2" {
+ t.Errorf("SessionID = %q, want %q (latest session)", found.SessionID, "list-session-2")
+ }
+
+ // Verify Agent comes from latest session metadata
+ if found.Agent != agent.AgentTypeClaudeCode {
+ t.Errorf("Agent = %q, want %q", found.Agent, agent.AgentTypeClaudeCode)
+ }
+}
+
+// TestWriteCommitted_SessionWithNoPrompts verifies that a session can be
+// written without prompts and still be read correctly.
+func TestWriteCommitted_SessionWithNoPrompts(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("313233343536")
+
+ // Write session without prompts
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "no-prompts-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"no_prompts": true}`)),
+ Prompts: nil, // No prompts
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Read the session content
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ // Verify session metadata is correct
+ if content.Metadata.SessionID != "no-prompts-session" {
+ t.Errorf("SessionID = %q, want %q", content.Metadata.SessionID, "no-prompts-session")
+ }
+
+ // Verify transcript is present
+ if len(content.Transcript) == 0 {
+ t.Error("Transcript should not be empty")
+ }
+
+ // Verify prompts is empty
+ if content.Prompts != "" {
+ t.Errorf("Prompts should be empty, got %q", content.Prompts)
+ }
+}
diff --git a/cli/checkpoint/checkpoint_3_test.go b/cli/checkpoint/checkpoint_3_test.go
new file mode 100644
index 0000000..d10e2f1
--- /dev/null
+++ b/cli/checkpoint/checkpoint_3_test.go
@@ -0,0 +1,737 @@
+package checkpoint
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestWriteCommitted_SessionWithSummary verifies that a non-nil Summary
+// in WriteCommittedOptions is persisted in the session-level metadata.json.
+// Regression test for ENT-243 where Summary was omitted from the struct literal.
+func TestWriteCommitted_SessionWithSummary(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeeff")
+
+ summary := &Summary{
+ Intent: "User wanted to fix a bug",
+ Outcome: "Bug was fixed",
+ }
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "summary-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"test": true}`)),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ Summary: summary,
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ if content.Metadata.Summary == nil {
+ t.Fatal("Summary should not be nil")
+ }
+ if content.Metadata.Summary.Intent != "User wanted to fix a bug" {
+ t.Errorf("Summary.Intent = %q, want %q", content.Metadata.Summary.Intent, "User wanted to fix a bug")
+ }
+ if content.Metadata.Summary.Outcome != "Bug was fixed" {
+ t.Errorf("Summary.Outcome = %q, want %q", content.Metadata.Summary.Outcome, "Bug was fixed")
+ }
+}
+
+// TestWriteCommitted_ThreeSessions verifies the structure with three sessions
+// to ensure the 0-based indexing works correctly throughout.
+func TestWriteCommitted_ThreeSessions(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("515253545556")
+
+ // Write three sessions
+ for i := range 3 {
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: fmt.Sprintf("three-session-%d", i),
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session_number": %d}`, i))),
+ FilesTouched: []string{fmt.Sprintf("s%d.go", i)},
+ CheckpointsCount: i + 1,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 100 * (i + 1),
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session %d error = %v", i, err)
+ }
+ }
+
+ // Read summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+
+ // Verify 3 sessions
+ if len(summary.Sessions) != 3 {
+ t.Errorf("len(summary.Sessions) = %d, want 3", len(summary.Sessions))
+ }
+
+ // Verify aggregated stats
+ // CheckpointsCount = 1 + 2 + 3 = 6
+ if summary.CheckpointsCount != 6 {
+ t.Errorf("summary.CheckpointsCount = %d, want 6", summary.CheckpointsCount)
+ }
+
+ // FilesTouched = [s0.go, s1.go, s2.go]
+ if len(summary.FilesTouched) != 3 {
+ t.Errorf("len(summary.FilesTouched) = %d, want 3", len(summary.FilesTouched))
+ }
+
+ // TokenUsage.InputTokens = 100 + 200 + 300 = 600
+ if summary.TokenUsage == nil {
+ t.Fatal("summary.TokenUsage should not be nil")
+ }
+ if summary.TokenUsage.InputTokens != 600 {
+ t.Errorf("summary.TokenUsage.InputTokens = %d, want 600", summary.TokenUsage.InputTokens)
+ }
+
+ // Verify each session can be read by index
+ for i := range 3 {
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, i)
+ if err != nil {
+ t.Errorf("ReadSessionContent(%d) error = %v", i, err)
+ continue
+ }
+ expectedID := fmt.Sprintf("three-session-%d", i)
+ if content.Metadata.SessionID != expectedID {
+ t.Errorf("session %d SessionID = %q, want %q", i, content.Metadata.SessionID, expectedID)
+ }
+ }
+}
+
+// TestReadCommitted_NonexistentCheckpoint verifies that ReadCommitted returns
+// nil (not an error) when the checkpoint doesn't exist.
+func TestReadCommitted_NonexistentCheckpoint(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+
+ // Ensure sessions branch exists
+ err := store.ensureSessionsBranch(context.Background())
+ if err != nil {
+ t.Fatalf("ensureSessionsBranch() error = %v", err)
+ }
+
+ // Try to read non-existent checkpoint
+ checkpointID := id.MustCheckpointID("ffffffffffff")
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Errorf("ReadCommitted() error = %v, want nil", err)
+ }
+ if summary != nil {
+ t.Errorf("ReadCommitted() = %v, want nil for non-existent checkpoint", summary)
+ }
+}
+
+// TestReadSessionContent_NonexistentCheckpoint verifies that ReadSessionContent
+// returns ErrCheckpointNotFound when the checkpoint doesn't exist.
+func TestReadSessionContent_NonexistentCheckpoint(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+
+ // Ensure sessions branch exists
+ err := store.ensureSessionsBranch(context.Background())
+ if err != nil {
+ t.Fatalf("ensureSessionsBranch() error = %v", err)
+ }
+
+ // Try to read from non-existent checkpoint
+ checkpointID := id.MustCheckpointID("eeeeeeeeeeee")
+ _, err = store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if !errors.Is(err, ErrCheckpointNotFound) {
+ t.Errorf("ReadSessionContent() error = %v, want ErrCheckpointNotFound", err)
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_CapturesModifiedTrackedFiles verifies that
+// the first checkpoint captures modifications to tracked files that existed before
+// the agent made any changes (user's uncommitted work).
+func TestWriteTemporary_FirstCheckpoint_CapturesModifiedTrackedFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit containing README.md
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit README.md with original content
+ readmeFile := filepath.Join(tempDir, "README.md")
+ originalContent := "# Original Content\n"
+ if err := os.WriteFile(readmeFile, []byte(originalContent), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // Simulate user modifying README.md BEFORE agent starts (user's uncommitted work)
+ modifiedContent := "# Modified by User\n\nThis change was made before the agent started.\n"
+ if err := os.WriteFile(readmeFile, []byte(modifiedContent), 0o644); err != nil {
+ t.Fatalf("failed to modify README: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ // Note: ModifiedFiles is empty because agent hasn't touched anything yet
+ // The first checkpoint should still capture README.md because it's modified in working dir
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{}, // Agent hasn't modified anything
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+ if result.Skipped {
+ t.Error("first checkpoint should not be skipped")
+ }
+
+ // Verify the shadow branch commit contains the MODIFIED README.md content
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // Find README.md in the tree
+ file, err := tree.File("README.md")
+ if err != nil {
+ t.Fatalf("README.md not found in checkpoint tree: %v", err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("failed to read README.md content: %v", err)
+ }
+
+ if content != modifiedContent {
+ t.Errorf("checkpoint should contain modified content\ngot:\n%s\nwant:\n%s", content, modifiedContent)
+ }
+}
+
+// TestWriteTemporary_PathNormalizationAndSkipping verifies that shadow branch writes
+// normalize absolute in-repo paths back to repo-relative tree entries and skip invalid
+// paths rather than encoding them into git trees.
+func TestWriteTemporary_PathNormalizationAndSkipping(t *testing.T) {
+ tests := []struct {
+ name string
+ modifiedFiles func(repoRoot, mainFile string) []string
+ wantUpdated bool
+ }{
+ {
+ name: "absolute in repo path is normalized",
+ modifiedFiles: func(_, mainFile string) []string {
+ return []string{mainFile}
+ },
+ wantUpdated: true,
+ },
+ {
+ name: "absolute outside repo path is skipped",
+ modifiedFiles: func(_, _ string) []string {
+ return []string{"C:/Users/rober/Vaults/Flowsign/main.go"}
+ },
+ wantUpdated: false,
+ },
+ {
+ name: "empty segment path is skipped",
+ modifiedFiles: func(_, _ string) []string {
+ return []string{"dir//main.go"}
+ },
+ wantUpdated: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ // Resolve symlinks so absolute paths match git's resolved repo root.
+ // On macOS, t.TempDir() returns /var/... but git resolves to /private/var/...
+ tempDir, err := filepath.EvalSymlinks(tempDir)
+ if err != nil {
+ t.Fatalf("failed to resolve symlinks: %v", err)
+ }
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ mainFile := filepath.Join(tempDir, "main.go")
+ if err := os.WriteFile(mainFile, []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write main.go: %v", err)
+ }
+ if _, err := worktree.Add("main.go"); err != nil {
+ t.Fatalf("failed to add main.go: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ updatedContent := "package main\n\nfunc main() {}\n"
+ if err := os.WriteFile(mainFile, []byte(updatedContent), 0o644); err != nil {
+ t.Fatalf("failed to update main.go: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: initialCommit.String(),
+ ModifiedFiles: tt.modifiedFiles(tempDir, mainFile),
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "Checkpoint with path normalization",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ assertNoEmptyEntryNames(t, repo, commit.TreeHash, "")
+
+ file, err := tree.File("main.go")
+ if err != nil {
+ t.Fatalf("main.go not found in checkpoint tree: %v", err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("failed to read main.go content: %v", err)
+ }
+
+ wantContent := "package main\n"
+ if tt.wantUpdated {
+ wantContent = updatedContent
+ }
+ if content != wantContent {
+ t.Errorf("unexpected main.go content\ngot:\n%s\nwant:\n%s", content, wantContent)
+ }
+ })
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles verifies that
+// the first checkpoint captures untracked files that exist in the working directory.
+func TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit README.md
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // Create an untracked file (simulating user creating a file before agent starts)
+ untrackedFile := filepath.Join(tempDir, "config.local.json")
+ untrackedContent := `{"key": "secret_value"}`
+ if err := os.WriteFile(untrackedFile, []byte(untrackedContent), 0o644); err != nil {
+ t.Fatalf("failed to write untracked file: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ NewFiles: []string{}, // NewFiles might be empty if this is truly "at session start"
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the shadow branch commit contains the untracked file
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // Find config.local.json in the tree
+ file, err := tree.File("config.local.json")
+ if err != nil {
+ t.Fatalf("untracked file config.local.json not found in checkpoint tree: %v", err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("failed to read config.local.json content: %v", err)
+ }
+
+ if content != untrackedContent {
+ t.Errorf("checkpoint should contain untracked file content\ngot:\n%s\nwant:\n%s", content, untrackedContent)
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_ExcludesGitIgnoredFiles verifies that
+// the first checkpoint does NOT capture files that are in .gitignore.
+func TestWriteTemporary_FirstCheckpoint_ExcludesGitIgnoredFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create .gitignore that ignores node_modules/
+ gitignoreFile := filepath.Join(tempDir, ".gitignore")
+ if err := os.WriteFile(gitignoreFile, []byte("node_modules/\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .gitignore: %v", err)
+ }
+ if _, err := worktree.Add(".gitignore"); err != nil {
+ t.Fatalf("failed to add .gitignore: %v", err)
+ }
+
+ // Create and commit README.md
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // Create node_modules/ directory with a file (should be ignored)
+ nodeModulesDir := filepath.Join(tempDir, "node_modules")
+ if err := os.MkdirAll(nodeModulesDir, 0o755); err != nil {
+ t.Fatalf("failed to create node_modules: %v", err)
+ }
+ ignoredFile := filepath.Join(nodeModulesDir, "some-package.js")
+ if err := os.WriteFile(ignoredFile, []byte("module.exports = {}"), 0o644); err != nil {
+ t.Fatalf("failed to write ignored file: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the shadow branch commit does NOT contain node_modules/
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // node_modules/some-package.js should NOT be in the tree
+ _, err = tree.File("node_modules/some-package.js")
+ if err == nil {
+ t.Error("gitignored file node_modules/some-package.js should NOT be in checkpoint tree")
+ } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
+ t.Fatalf("expected node_modules/some-package.js to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
+ }
+}
+
+// TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredModifiedFiles verifies that
+// subsequent checkpoints (IsFirstCheckpoint=false) filter out gitignored files from
+// ModifiedFiles. This is a security-critical test: if an agent modifies a .env file
+// and reports it in its transcript, the .env file must NOT leak into the shadow branch.
+// See: https://techstackups.com/guides/trace-io-hands-on-what-it-actually-captures/#what-leaks-into-checkpoints
+func TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredModifiedFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ testutil.InitRepo(t, tempDir)
+ repo, err := git.PlainOpen(tempDir)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create .gitignore that ignores .env files
+ gitignoreContent := ".env\n*.secret\nnode_modules/\n"
+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
+ t.Fatalf("failed to write .gitignore: %v", err)
+ }
+ if _, err := worktree.Add(".gitignore"); err != nil {
+ t.Fatalf("failed to add .gitignore: %v", err)
+ }
+
+ // Create and commit a tracked file
+ if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write main.go: %v", err)
+ }
+ if _, err := worktree.Add("main.go"); err != nil {
+ t.Fatalf("failed to add main.go: %v", err)
+ }
+ testutil.GitCommit(t, tempDir, "Initial commit")
+ headRef, err := repo.Head()
+ require.NoError(t, err)
+ initialCommit := headRef.Hash()
+
+ // Create gitignored files on disk (simulating an agent creating/modifying them)
+ if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("API_KEY=sk-secret-1234\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .env: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tempDir, "db.secret"), []byte("password=hunter2\n"), 0o644); err != nil {
+ t.Fatalf("failed to write db.secret: %v", err)
+ }
+
+ // Also modify a tracked file (this SHOULD be captured)
+ if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
+ t.Fatalf("failed to modify main.go: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ // Write first checkpoint to establish the shadow branch
+ firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("first WriteTemporary() error = %v", err)
+ }
+ require.False(t, firstResult.Skipped)
+
+ // Now write a subsequent checkpoint where the agent reports .env and db.secret
+ // as modified files (e.g., agent touched them during its turn).
+ // These gitignored files must NOT appear in the checkpoint tree.
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{"main.go", ".env", "db.secret"}, // Agent reports these
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "Second checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: false,
+ })
+ if err != nil {
+ t.Fatalf("second WriteTemporary() error = %v", err)
+ }
+
+ // Verify the checkpoint tree (use returned commit hash — works whether skipped or not)
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // main.go SHOULD be in the tree (tracked file, legitimately modified)
+ _, err = tree.File("main.go")
+ if err != nil {
+ t.Errorf("main.go should be in checkpoint tree: %v", err)
+ }
+
+ // .env MUST NOT be in the tree (gitignored — contains API key)
+ _, err = tree.File(".env")
+ if err == nil {
+ t.Error("SECURITY: gitignored file .env leaked into checkpoint tree — API keys exposed on shadow branch")
+ }
+
+ // db.secret MUST NOT be in the tree (gitignored)
+ _, err = tree.File("db.secret")
+ if err == nil {
+ t.Error("SECURITY: gitignored file db.secret leaked into checkpoint tree — secrets exposed on shadow branch")
+ }
+}
diff --git a/cli/checkpoint/checkpoint_4_test.go b/cli/checkpoint/checkpoint_4_test.go
new file mode 100644
index 0000000..6120e3b
--- /dev/null
+++ b/cli/checkpoint/checkpoint_4_test.go
@@ -0,0 +1,791 @@
+package checkpoint
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredNewFiles verifies that
+// subsequent checkpoints filter out gitignored files from NewFiles.
+func TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredNewFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ testutil.InitRepo(t, tempDir)
+ repo, err := git.PlainOpen(tempDir)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create .gitignore
+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(".env\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .gitignore: %v", err)
+ }
+ if _, err := worktree.Add(".gitignore"); err != nil {
+ t.Fatalf("failed to add .gitignore: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(tempDir, "README.md"), []byte("# Test\n"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ testutil.GitCommit(t, tempDir, "Initial commit")
+ headRef, err := repo.Head()
+ require.NoError(t, err)
+ initialCommit := headRef.Hash()
+
+ // Create the gitignored file and a legitimate new file on disk
+ if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("SECRET=abc123\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .env: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tempDir, "config.go"), []byte("package config\n"), 0o644); err != nil {
+ t.Fatalf("failed to write config.go: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ // First checkpoint
+ firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("first WriteTemporary() error = %v", err)
+ }
+ require.False(t, firstResult.Skipped)
+
+ // Subsequent checkpoint with .env reported as a new file
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"config.go", ".env"}, // Agent created both
+ DeletedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "Second checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: false,
+ })
+ if err != nil {
+ t.Fatalf("second WriteTemporary() error = %v", err)
+ }
+
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // config.go SHOULD be in the tree
+ _, err = tree.File("config.go")
+ if err != nil {
+ t.Errorf("config.go should be in checkpoint tree: %v", err)
+ }
+
+ // .env MUST NOT be in the tree
+ _, err = tree.File(".env")
+ if err == nil {
+ t.Error("SECURITY: gitignored file .env leaked into checkpoint tree via NewFiles")
+ }
+}
+
+// TestWriteTemporary_SubsequentCheckpoint_ExcludesNestedGitIgnoredFiles verifies that
+// gitignore patterns with directory wildcards (e.g., node_modules/) work for
+// subsequent checkpoints, not just the first checkpoint.
+func TestWriteTemporary_SubsequentCheckpoint_ExcludesNestedGitIgnoredFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ testutil.InitRepo(t, tempDir)
+ repo, err := git.PlainOpen(tempDir)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("node_modules/\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .gitignore: %v", err)
+ }
+ if _, err := worktree.Add(".gitignore"); err != nil {
+ t.Fatalf("failed to add .gitignore: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(tempDir, "index.js"), []byte("console.log('hello')\n"), 0o644); err != nil {
+ t.Fatalf("failed to write index.js: %v", err)
+ }
+ if _, err := worktree.Add("index.js"); err != nil {
+ t.Fatalf("failed to add index.js: %v", err)
+ }
+ testutil.GitCommit(t, tempDir, "Initial commit")
+ headRef, err := repo.Head()
+ require.NoError(t, err)
+ initialCommit := headRef.Hash()
+
+ // Create node_modules file on disk
+ if err := os.MkdirAll(filepath.Join(tempDir, "node_modules", "pkg"), 0o755); err != nil {
+ t.Fatalf("failed to create node_modules: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tempDir, "node_modules", "pkg", "index.js"), []byte("module.exports = {}"), 0o644); err != nil {
+ t.Fatalf("failed to write node_modules file: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ // First checkpoint
+ firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("first WriteTemporary() error = %v", err)
+ }
+ require.False(t, firstResult.Skipped)
+
+ // Subsequent checkpoint with node_modules file reported as modified
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{"index.js", "node_modules/pkg/index.js"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "Second checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: false,
+ })
+ if err != nil {
+ t.Fatalf("second WriteTemporary() error = %v", err)
+ }
+
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // index.js SHOULD be in the tree
+ _, err = tree.File("index.js")
+ if err != nil {
+ t.Errorf("index.js should be in checkpoint tree: %v", err)
+ }
+
+ // node_modules/pkg/index.js MUST NOT be in the tree
+ _, err = tree.File("node_modules/pkg/index.js")
+ if err == nil {
+ t.Error("SECURITY: gitignored file node_modules/pkg/index.js leaked into checkpoint tree")
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_UserAndAgentChanges verifies that
+// the first checkpoint captures both user's pre-existing changes and agent changes.
+func TestWriteTemporary_FirstCheckpoint_UserAndAgentChanges(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit README.md and main.go
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Original\n"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ mainFile := filepath.Join(tempDir, "main.go")
+ if err := os.WriteFile(mainFile, []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write main.go: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ if _, err := worktree.Add("main.go"); err != nil {
+ t.Fatalf("failed to add main.go: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // User modifies README.md BEFORE agent starts
+ userModifiedContent := "# Modified by User\n"
+ if err := os.WriteFile(readmeFile, []byte(userModifiedContent), 0o644); err != nil {
+ t.Fatalf("failed to modify README: %v", err)
+ }
+
+ // Agent modifies main.go
+ agentModifiedContent := "package main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n"
+ if err := os.WriteFile(mainFile, []byte(agentModifiedContent), 0o644); err != nil {
+ t.Fatalf("failed to modify main.go: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint - agent reports main.go as modified (from transcript)
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{"main.go"}, // Only agent-modified file in list
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the checkpoint contains BOTH changes
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // Check README.md has user's modification
+ readmeTreeFile, err := tree.File("README.md")
+ if err != nil {
+ t.Fatalf("README.md not found in tree: %v", err)
+ }
+ readmeContent, err := readmeTreeFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read README.md content: %v", err)
+ }
+ if readmeContent != userModifiedContent {
+ t.Errorf("README.md should have user's modification\ngot:\n%s\nwant:\n%s", readmeContent, userModifiedContent)
+ }
+
+ // Check main.go has agent's modification
+ mainTreeFile, err := tree.File("main.go")
+ if err != nil {
+ t.Fatalf("main.go not found in tree: %v", err)
+ }
+ mainContent, err := mainTreeFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read main.go content: %v", err)
+ }
+ if mainContent != agentModifiedContent {
+ t.Errorf("main.go should have agent's modification\ngot:\n%s\nwant:\n%s", mainContent, agentModifiedContent)
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_CapturesUserDeletedFiles verifies that
+// the first checkpoint excludes files that the user deleted before the session started.
+func TestWriteTemporary_FirstCheckpoint_CapturesUserDeletedFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit two files
+ keepFile := filepath.Join(tempDir, "keep.txt")
+ if err := os.WriteFile(keepFile, []byte("keep this"), 0o644); err != nil {
+ t.Fatalf("failed to write keep.txt: %v", err)
+ }
+ deleteFile := filepath.Join(tempDir, "delete-me.txt")
+ if err := os.WriteFile(deleteFile, []byte("delete this"), 0o644); err != nil {
+ t.Fatalf("failed to write delete-me.txt: %v", err)
+ }
+
+ if _, err := worktree.Add("keep.txt"); err != nil {
+ t.Fatalf("failed to add keep.txt: %v", err)
+ }
+ if _, err := worktree.Add("delete-me.txt"); err != nil {
+ t.Fatalf("failed to add delete-me.txt: %v", err)
+ }
+
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // User deletes delete-me.txt BEFORE the session starts
+ if err := os.Remove(deleteFile); err != nil {
+ t.Fatalf("failed to delete file: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ DeletedFiles: []string{}, // No agent deletions
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the checkpoint tree
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // keep.txt should be in the tree (unchanged from HEAD)
+ if _, err := tree.File("keep.txt"); err != nil {
+ t.Errorf("keep.txt should be in checkpoint tree: %v", err)
+ }
+
+ // delete-me.txt should NOT be in the tree (user deleted it)
+ _, err = tree.File("delete-me.txt")
+ if err == nil {
+ t.Error("delete-me.txt should NOT be in checkpoint tree (user deleted it before session)")
+ } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
+ t.Fatalf("expected delete-me.txt to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_CapturesRenamedFiles verifies that
+// the first checkpoint captures renamed files correctly.
+func TestWriteTemporary_FirstCheckpoint_CapturesRenamedFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit a file
+ oldFile := filepath.Join(tempDir, "old-name.txt")
+ if err := os.WriteFile(oldFile, []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write old-name.txt: %v", err)
+ }
+
+ if _, err := worktree.Add("old-name.txt"); err != nil {
+ t.Fatalf("failed to add old-name.txt: %v", err)
+ }
+
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // User renames the file using git mv BEFORE the session starts
+ // Using git mv ensures git reports this as R (rename) status, not separate D+A
+ cmd := exec.CommandContext(context.Background(), "git", "mv", "old-name.txt", "new-name.txt")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to git mv: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the checkpoint tree
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // new-name.txt should be in the tree
+ if _, err := tree.File("new-name.txt"); err != nil {
+ t.Errorf("new-name.txt should be in checkpoint tree: %v", err)
+ }
+
+ // old-name.txt should NOT be in the tree (renamed away)
+ _, err = tree.File("old-name.txt")
+ if err == nil {
+ t.Error("old-name.txt should NOT be in checkpoint tree (file was renamed)")
+ } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
+ t.Fatalf("expected old-name.txt to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
+ }
+}
+
+// TestWriteTemporary_FirstCheckpoint_FilenamesWithSpaces verifies that
+// filenames with spaces are handled correctly.
+func TestWriteTemporary_FirstCheckpoint_FilenamesWithSpaces(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit a simple file first
+ simpleFile := filepath.Join(tempDir, "simple.txt")
+ if err := os.WriteFile(simpleFile, []byte("simple"), 0o644); err != nil {
+ t.Fatalf("failed to write simple.txt: %v", err)
+ }
+
+ if _, err := worktree.Add("simple.txt"); err != nil {
+ t.Fatalf("failed to add simple.txt: %v", err)
+ }
+
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // User creates a file with spaces in the name
+ spacesFile := filepath.Join(tempDir, "file with spaces.txt")
+ if err := os.WriteFile(spacesFile, []byte("content with spaces"), 0o644); err != nil {
+ t.Fatalf("failed to write file with spaces: %v", err)
+ }
+
+ // Change to temp dir so paths.WorktreeRoot() works correctly
+ t.Chdir(tempDir)
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create checkpoint store and write first checkpoint
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: ".trace/metadata/test-session",
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() error = %v", err)
+ }
+
+ // Verify the checkpoint tree
+ commit, err := repo.CommitObject(result.CommitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // "file with spaces.txt" should be in the tree with correct name
+ if _, err := tree.File("file with spaces.txt"); err != nil {
+ t.Errorf("'file with spaces.txt' should be in checkpoint tree: %v", err)
+ }
+}
+
+// =============================================================================
+// Duplicate Session ID Tests - Tests for ENT-252 where the same session ID
+// written twice to the same checkpoint should update in-place, not append.
+// =============================================================================
+
+// TestWriteCommitted_DuplicateSessionIDUpdatesInPlace verifies that writing
+// the same session ID twice to the same checkpoint updates the existing slot
+// rather than creating a duplicate subdirectory.
+func TestWriteCommitted_DuplicateSessionIDUpdatesInPlace(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("deda01234567")
+
+ // Write session "X" with initial data
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-X",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "session X v1"}`)),
+ FilesTouched: []string{"a.go"},
+ CheckpointsCount: 3,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ APICallCount: 5,
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session X v1 error = %v", err)
+ }
+
+ // Write session "Y"
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-Y",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "session Y"}`)),
+ FilesTouched: []string{"b.go"},
+ CheckpointsCount: 2,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 50,
+ OutputTokens: 25,
+ APICallCount: 3,
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session Y error = %v", err)
+ }
+
+ // Write session "X" again with updated data (should replace, not append)
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-X",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "session X v2"}`)),
+ FilesTouched: []string{"a.go", "c.go"},
+ CheckpointsCount: 5,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 200,
+ OutputTokens: 100,
+ APICallCount: 10,
+ },
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session X v2 error = %v", err)
+ }
+
+ // Read the checkpoint summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+ require.NotNil(t, summary, "ReadCommitted() returned nil summary")
+
+ // Should have 2 sessions, not 3
+ if len(summary.Sessions) != 2 {
+ t.Errorf("len(summary.Sessions) = %d, want 2 (not 3 - duplicate should be replaced)", len(summary.Sessions))
+ }
+
+ // Verify session 0 has updated data (session X v2)
+ content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if content0.Metadata.SessionID != "session-X" {
+ t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-X")
+ }
+ if content0.Metadata.CheckpointsCount != 5 {
+ t.Errorf("session 0 CheckpointsCount = %d, want 5", content0.Metadata.CheckpointsCount)
+ }
+ if !strings.Contains(string(content0.Transcript), "session X v2") {
+ t.Errorf("session 0 transcript should contain 'session X v2', got %s", string(content0.Transcript))
+ }
+
+ // Verify session 1 is still "Y" (unchanged)
+ content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(1) error = %v", err)
+ }
+ if content1.Metadata.SessionID != "session-Y" {
+ t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-Y")
+ }
+
+ // Verify aggregated stats: count = 5 (X v2) + 2 (Y) = 7
+ if summary.CheckpointsCount != 7 {
+ t.Errorf("summary.CheckpointsCount = %d, want 7", summary.CheckpointsCount)
+ }
+
+ // Verify merged files: [a.go, b.go, c.go]
+ expectedFiles := []string{"a.go", "b.go", "c.go"}
+ if len(summary.FilesTouched) != len(expectedFiles) {
+ t.Errorf("len(summary.FilesTouched) = %d, want %d", len(summary.FilesTouched), len(expectedFiles))
+ }
+ for i, want := range expectedFiles {
+ if i < len(summary.FilesTouched) && summary.FilesTouched[i] != want {
+ t.Errorf("summary.FilesTouched[%d] = %q, want %q", i, summary.FilesTouched[i], want)
+ }
+ }
+
+ // Verify aggregated tokens: 200 (X v2) + 50 (Y) = 250
+ if summary.TokenUsage == nil {
+ t.Fatal("summary.TokenUsage should not be nil")
+ }
+ if summary.TokenUsage.InputTokens != 250 {
+ t.Errorf("summary.TokenUsage.InputTokens = %d, want 250", summary.TokenUsage.InputTokens)
+ }
+ if summary.TokenUsage.OutputTokens != 125 {
+ t.Errorf("summary.TokenUsage.OutputTokens = %d, want 125", summary.TokenUsage.OutputTokens)
+ }
+ if summary.TokenUsage.APICallCount != 13 {
+ t.Errorf("summary.TokenUsage.APICallCount = %d, want 13", summary.TokenUsage.APICallCount)
+ }
+}
diff --git a/cli/checkpoint/checkpoint_5_test.go b/cli/checkpoint/checkpoint_5_test.go
new file mode 100644
index 0000000..4b75811
--- /dev/null
+++ b/cli/checkpoint/checkpoint_5_test.go
@@ -0,0 +1,798 @@
+package checkpoint
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/versioninfo"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestWriteCommitted_DuplicateSessionIDSingleSession verifies that writing
+// the same session ID twice when it's the only session updates in-place.
+func TestWriteCommitted_DuplicateSessionIDSingleSession(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("dedb07654321")
+
+ // Write session "X" with initial data
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-X",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "v1"}`)),
+ FilesTouched: []string{"old.go"},
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() v1 error = %v", err)
+ }
+
+ // Write session "X" again with updated data
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-X",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"message": "v2"}`)),
+ FilesTouched: []string{"new.go"},
+ CheckpointsCount: 5,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() v2 error = %v", err)
+ }
+
+ // Read the checkpoint summary
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+ require.NotNil(t, summary, "ReadCommitted() returned nil summary")
+
+ // Should have 1 session, not 2
+ if len(summary.Sessions) != 1 {
+ t.Errorf("len(summary.Sessions) = %d, want 1 (duplicate should be replaced)", len(summary.Sessions))
+ }
+
+ // Verify session has updated data
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if content.Metadata.SessionID != "session-X" {
+ t.Errorf("session 0 SessionID = %q, want %q", content.Metadata.SessionID, "session-X")
+ }
+ if content.Metadata.CheckpointsCount != 5 {
+ t.Errorf("session 0 CheckpointsCount = %d, want 5 (updated value)", content.Metadata.CheckpointsCount)
+ }
+ if !strings.Contains(string(content.Transcript), "v2") {
+ t.Errorf("session 0 transcript should contain 'v2', got %s", string(content.Transcript))
+ }
+
+ // Verify aggregated stats match the single session
+ if summary.CheckpointsCount != 5 {
+ t.Errorf("summary.CheckpointsCount = %d, want 5", summary.CheckpointsCount)
+ }
+ expectedFiles := []string{"new.go"}
+ if len(summary.FilesTouched) != 1 || summary.FilesTouched[0] != "new.go" {
+ t.Errorf("summary.FilesTouched = %v, want %v", summary.FilesTouched, expectedFiles)
+ }
+}
+
+// TestWriteCommitted_DuplicateSessionIDReusesIndex verifies that when a session ID
+// already exists at index 0, writing it again reuses index 0 (not index 2).
+// The session file paths in the summary must point to /0/, not /2/.
+func TestWriteCommitted_DuplicateSessionIDReusesIndex(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("dedc0abcdef1")
+
+ // Write session A at index 0
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-A",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"v": 1}`)),
+ CheckpointsCount: 1,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session A error = %v", err)
+ }
+
+ // Write session B at index 1
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-B",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"v": 2}`)),
+ CheckpointsCount: 1,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session B error = %v", err)
+ }
+
+ // Write session A again — should reuse index 0, not create index 2
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-A",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"v": 3}`)),
+ CheckpointsCount: 2,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() session A v2 error = %v", err)
+ }
+
+ summary, err := store.ReadCommitted(context.Background(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadCommitted() error = %v", err)
+ }
+
+ // Must still be 2 sessions
+ if len(summary.Sessions) != 2 {
+ t.Fatalf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
+ }
+
+ // Session A's file paths must point to subdirectory /0/, not /2/
+ if !strings.Contains(summary.Sessions[0].Transcript, "/0/") {
+ t.Errorf("session A should be at index 0, got transcript path %s", summary.Sessions[0].Transcript)
+ }
+
+ // Session B stays at /1/
+ if !strings.Contains(summary.Sessions[1].Transcript, "/1/") {
+ t.Errorf("session B should be at index 1, got transcript path %s", summary.Sessions[1].Transcript)
+ }
+
+ // Verify index 0 has the updated content
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if content.Metadata.SessionID != "session-A" {
+ t.Errorf("session 0 SessionID = %q, want %q", content.Metadata.SessionID, "session-A")
+ }
+ if !strings.Contains(string(content.Transcript), `"v": 3`) {
+ t.Errorf("session 0 should have updated transcript, got %s", string(content.Transcript))
+ }
+}
+
+// TestWriteCommitted_DuplicateSessionIDClearsStaleFiles verifies that when a session
+// is overwritten in-place, optional files from the previous write (prompts, context)
+// do not persist if the new write omits them, and sibling session data is untouched.
+func TestWriteCommitted_DuplicateSessionIDClearsStaleFiles(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("dedd0abcdef2")
+
+ // Write session A with prompts and context
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-A",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"v": 1}`)),
+ Prompts: []string{"original prompt"},
+ CheckpointsCount: 1,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() A v1 error = %v", err)
+ }
+
+ // Write session B with prompts
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-B",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"session": "B"}`)),
+ Prompts: []string{"B prompt"},
+ CheckpointsCount: 1,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() B error = %v", err)
+ }
+
+ // Overwrite session A WITHOUT prompts
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "session-A",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"v": 2}`)),
+ Prompts: nil,
+ CheckpointsCount: 2,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() A v2 error = %v", err)
+ }
+
+ // Session A: stale prompts should be cleared
+ contentA, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(0) error = %v", err)
+ }
+ if contentA.Prompts != "" {
+ t.Errorf("session A stale prompts should be cleared, got %q", contentA.Prompts)
+ }
+ if !strings.Contains(string(contentA.Transcript), `"v": 2`) {
+ t.Errorf("session A transcript should be updated, got %s", string(contentA.Transcript))
+ }
+
+ // Session B: data must be untouched
+ contentB, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
+ if err != nil {
+ t.Fatalf("ReadSessionContent(1) error = %v", err)
+ }
+ if contentB.Metadata.SessionID != "session-B" {
+ t.Errorf("session B SessionID = %q, want %q", contentB.Metadata.SessionID, "session-B")
+ }
+ if !strings.Contains(contentB.Prompts, "B prompt") {
+ t.Errorf("session B prompts should be preserved, got %q", contentB.Prompts)
+ }
+}
+
+// highEntropySecret is a string with Shannon entropy > 4.5 that will trigger redaction.
+const highEntropySecret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"
+
+func TestWriteCommitted_PreservesRedactedTranscript(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeef1")
+
+ // Callers redact before passing to WriteCommitted; the store persists as-is.
+ rawTranscript := []byte(`{"role":"assistant","content":"Here is your key: ` + highEntropySecret + `"}` + "\n")
+ redactedTranscript, err := redact.JSONLBytes(rawTranscript)
+ if err != nil {
+ t.Fatalf("redact.JSONLBytes() error = %v", err)
+ }
+
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "redact-transcript-session",
+ Strategy: "manual-commit",
+ Transcript: redactedTranscript,
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ if strings.Contains(string(content.Transcript), highEntropySecret) {
+ t.Error("transcript should not contain the secret after redaction")
+ }
+ if !strings.Contains(string(content.Transcript), "REDACTED") {
+ t.Error("transcript should contain REDACTED placeholder")
+ }
+}
+
+func TestWriteCommitted_RedactsPromptSecrets(t *testing.T) {
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeef2")
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "redact-prompt-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}`)),
+ Prompts: []string{"Set API_KEY=" + highEntropySecret},
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ if strings.Contains(content.Prompts, highEntropySecret) {
+ t.Error("prompts should not contain the secret after redaction")
+ }
+ if !strings.Contains(content.Prompts, "REDACTED") {
+ t.Error("prompts should contain REDACTED placeholder")
+ }
+}
+
+func TestCopyMetadataDir_RedactsSecrets(t *testing.T) {
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ metadataDir := filepath.Join(tempDir, "metadata")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ // Write a JSONL file with a secret
+ jsonlFile := filepath.Join(metadataDir, "agent.jsonl")
+ if err := os.WriteFile(jsonlFile, []byte(`{"content":"key=`+highEntropySecret+`"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write jsonl file: %v", err)
+ }
+
+ // Write a plain text file with a secret
+ txtFile := filepath.Join(metadataDir, "notes.txt")
+ if err := os.WriteFile(txtFile, []byte("secret: "+highEntropySecret), 0o644); err != nil {
+ t.Fatalf("failed to write txt file: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ entries := make(map[string]object.TreeEntry)
+
+ if err := store.copyMetadataDir(metadataDir, "cp/", entries); err != nil {
+ t.Fatalf("copyMetadataDir() error = %v", err)
+ }
+
+ // Verify both files were added
+ if _, ok := entries["cp/agent.jsonl"]; !ok {
+ t.Fatal("agent.jsonl should be in entries")
+ }
+ if _, ok := entries["cp/notes.txt"]; !ok {
+ t.Fatal("notes.txt should be in entries")
+ }
+
+ // Read back the blob content and verify redaction
+ for path, entry := range entries {
+ blob, bErr := repo.BlobObject(entry.Hash)
+ if bErr != nil {
+ t.Fatalf("failed to read blob for %s: %v", path, bErr)
+ }
+ reader, rErr := blob.Reader()
+ if rErr != nil {
+ t.Fatalf("failed to get reader for %s: %v", path, rErr)
+ }
+ buf := make([]byte, blob.Size)
+ if _, rErr = reader.Read(buf); rErr != nil && rErr.Error() != "EOF" {
+ t.Fatalf("failed to read blob content for %s: %v", path, rErr)
+ }
+ reader.Close()
+
+ content := string(buf)
+ if strings.Contains(content, highEntropySecret) {
+ t.Errorf("%s should not contain the secret after redaction", path)
+ }
+ if !strings.Contains(content, "REDACTED") {
+ t.Errorf("%s should contain REDACTED placeholder", path)
+ }
+ }
+}
+
+// TestWriteCommitted_CLIVersionField verifies that versioninfo.Version is written
+// to both the root CheckpointSummary and session-level CommittedMetadata.
+func TestWriteCommitted_CLIVersionField(t *testing.T) {
+ t.Parallel()
+
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ store := NewGitStore(repo)
+
+ checkpointID := id.MustCheckpointID("b1c2d3e4f5a6")
+ sessionID := "test-session-version"
+
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: sessionID,
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte("test transcript")),
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Read the metadata branch
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get metadata branch reference: %v", err)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ checkpointTree, err := tree.Tree(checkpointID.Path())
+ if err != nil {
+ t.Fatalf("failed to find checkpoint tree at %s: %v", checkpointID.Path(), err)
+ }
+
+ // Verify root metadata.json (CheckpointSummary) has CLIVersion
+ metadataFile, err := checkpointTree.File(paths.MetadataFileName)
+ if err != nil {
+ t.Fatalf("failed to find root metadata.json: %v", err)
+ }
+
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read root metadata.json: %v", err)
+ }
+
+ var summary CheckpointSummary
+ if err := json.Unmarshal([]byte(content), &summary); err != nil {
+ t.Fatalf("failed to parse root metadata.json: %v", err)
+ }
+
+ if summary.CLIVersion != versioninfo.Version {
+ t.Errorf("CheckpointSummary.CLIVersion = %q, want %q", summary.CLIVersion, versioninfo.Version)
+ }
+
+ // Verify session-level metadata.json (CommittedMetadata) has CLIVersion
+ sessionTree, err := checkpointTree.Tree("0")
+ if err != nil {
+ t.Fatalf("failed to get session tree: %v", err)
+ }
+
+ sessionMetadataFile, err := sessionTree.File(paths.MetadataFileName)
+ if err != nil {
+ t.Fatalf("failed to find session metadata.json: %v", err)
+ }
+
+ sessionContent, err := sessionMetadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read session metadata.json: %v", err)
+ }
+
+ var sessionMetadata CommittedMetadata
+ if err := json.Unmarshal([]byte(sessionContent), &sessionMetadata); err != nil {
+ t.Fatalf("failed to parse session metadata.json: %v", err)
+ }
+
+ if sessionMetadata.CLIVersion != versioninfo.Version {
+ t.Errorf("CommittedMetadata.CLIVersion = %q, want %q", sessionMetadata.CLIVersion, versioninfo.Version)
+ }
+}
+
+func TestWriteCommitted_ModelFieldAlwaysPresent(t *testing.T) {
+ t.Parallel()
+
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ store := NewGitStore(repo)
+
+ checkpointID := id.MustCheckpointID("c1d2e3f4a5b6")
+ err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "test-session-model",
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte("test transcript")),
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get metadata branch reference: %v", err)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ sessionMetadataFile, err := tree.File(sessionMetadataPath)
+ if err != nil {
+ t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
+ }
+
+ sessionContent, err := sessionMetadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read session metadata.json: %v", err)
+ }
+
+ var sessionMetadata CommittedMetadata
+ if err := json.Unmarshal([]byte(sessionContent), &sessionMetadata); err != nil {
+ t.Fatalf("failed to parse session metadata.json: %v", err)
+ }
+
+ if sessionMetadata.Model != "" {
+ t.Errorf("CommittedMetadata.Model = %q, want empty string", sessionMetadata.Model)
+ }
+ if !strings.Contains(sessionContent, `"model": ""`) {
+ t.Errorf("session metadata.json should contain an explicit empty model field, got:\n%s", sessionContent)
+ }
+}
+
+func TestRedactSummary_Nil(t *testing.T) {
+ t.Parallel()
+ result := redactSummary(nil)
+ if result != nil {
+ t.Error("redactSummary(nil) should return nil")
+ }
+}
+
+func TestRedactSummary_WithSecrets(t *testing.T) {
+ t.Parallel()
+ summary := &Summary{
+ Intent: "Set API_KEY=" + highEntropySecret,
+ Outcome: "Configured key " + highEntropySecret + " successfully",
+ Friction: []string{
+ "Had to find " + highEntropySecret + " in env",
+ "No issues here",
+ },
+ OpenItems: []string{
+ "Rotate " + highEntropySecret,
+ },
+ Learnings: LearningsSummary{
+ Repo: []string{
+ "Found secret " + highEntropySecret + " in config",
+ },
+ Workflow: []string{
+ "Use vault for " + highEntropySecret,
+ },
+ Code: []CodeLearning{
+ {
+ Path: "config/secrets.go",
+ Line: 42,
+ EndLine: 50,
+ Finding: "Key " + highEntropySecret + " is hardcoded",
+ },
+ },
+ },
+ }
+
+ result := redactSummary(summary)
+
+ // Verify secrets are removed from all text fields
+ if strings.Contains(result.Intent, highEntropySecret) {
+ t.Error("Intent should not contain the secret")
+ }
+ if !strings.Contains(result.Intent, "REDACTED") {
+ t.Error("Intent should contain REDACTED placeholder")
+ }
+
+ if strings.Contains(result.Outcome, highEntropySecret) {
+ t.Error("Outcome should not contain the secret")
+ }
+
+ if strings.Contains(result.Friction[0], highEntropySecret) {
+ t.Error("Friction[0] should not contain the secret")
+ }
+ if result.Friction[1] != "No issues here" {
+ t.Errorf("Friction[1] should be unchanged, got %q", result.Friction[1])
+ }
+
+ if strings.Contains(result.OpenItems[0], highEntropySecret) {
+ t.Error("OpenItems[0] should not contain the secret")
+ }
+
+ if strings.Contains(result.Learnings.Repo[0], highEntropySecret) {
+ t.Error("Learnings.Repo[0] should not contain the secret")
+ }
+
+ if strings.Contains(result.Learnings.Workflow[0], highEntropySecret) {
+ t.Error("Learnings.Workflow[0] should not contain the secret")
+ }
+
+ // Verify CodeLearning structural fields preserved, Finding redacted
+ cl := result.Learnings.Code[0]
+ if cl.Path != "config/secrets.go" {
+ t.Errorf("CodeLearning.Path should be preserved, got %q", cl.Path)
+ }
+ if cl.Line != 42 {
+ t.Errorf("CodeLearning.Line should be preserved, got %d", cl.Line)
+ }
+ if cl.EndLine != 50 {
+ t.Errorf("CodeLearning.EndLine should be preserved, got %d", cl.EndLine)
+ }
+ if strings.Contains(cl.Finding, highEntropySecret) {
+ t.Error("CodeLearning.Finding should not contain the secret")
+ }
+ if !strings.Contains(cl.Finding, "REDACTED") {
+ t.Error("CodeLearning.Finding should contain REDACTED placeholder")
+ }
+
+ // Verify original is not mutated
+ if !strings.Contains(summary.Intent, highEntropySecret) {
+ t.Error("original Summary.Intent should not be mutated")
+ }
+}
+
+func TestRedactSummary_NoSecrets(t *testing.T) {
+ t.Parallel()
+ summary := &Summary{
+ Intent: "Fix a bug",
+ Outcome: "Bug fixed",
+ Friction: []string{"None"},
+ OpenItems: []string{},
+ Learnings: LearningsSummary{
+ Repo: []string{"Found the pattern"},
+ Workflow: []string{"Use TDD"},
+ Code: []CodeLearning{
+ {Path: "main.go", Line: 1, Finding: "Good code"},
+ },
+ },
+ }
+
+ result := redactSummary(summary)
+
+ if result.Intent != "Fix a bug" {
+ t.Errorf("Intent should be unchanged, got %q", result.Intent)
+ }
+ if result.Outcome != "Bug fixed" {
+ t.Errorf("Outcome should be unchanged, got %q", result.Outcome)
+ }
+ if result.Learnings.Code[0].Finding != "Good code" {
+ t.Errorf("Finding should be unchanged, got %q", result.Learnings.Code[0].Finding)
+ }
+}
+
+func TestRedactStringSlice_NilAndEmpty(t *testing.T) {
+ t.Parallel()
+
+ // nil input should return nil (not empty slice)
+ if result := redactStringSlice(nil); result != nil {
+ t.Errorf("redactStringSlice(nil) should return nil, got %v", result)
+ }
+
+ // empty slice should return empty slice (not nil)
+ result := redactStringSlice([]string{})
+ if result == nil {
+ t.Error("redactStringSlice([]string{}) should return empty slice, not nil")
+ }
+ if len(result) != 0 {
+ t.Errorf("redactStringSlice([]string{}) should return empty slice, got len %d", len(result))
+ }
+}
+
+func TestRedactCodeLearnings_NilAndEmpty(t *testing.T) {
+ t.Parallel()
+
+ // nil input should return nil
+ if result := redactCodeLearnings(nil); result != nil {
+ t.Errorf("redactCodeLearnings(nil) should return nil, got %v", result)
+ }
+
+ // empty slice should return empty slice
+ result := redactCodeLearnings([]CodeLearning{})
+ if result == nil {
+ t.Error("redactCodeLearnings([]CodeLearning{}) should return empty slice, not nil")
+ }
+ if len(result) != 0 {
+ t.Errorf("expected len 0, got %d", len(result))
+ }
+}
+
+func TestWriteCommitted_RedactsSummarySecrets(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeef7")
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "redact-summary-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ Summary: &Summary{
+ Intent: "Used key " + highEntropySecret + " to auth",
+ Outcome: "Authenticated with " + highEntropySecret,
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ if content.Metadata.Summary == nil {
+ t.Fatal("Summary should not be nil")
+ }
+ if strings.Contains(content.Metadata.Summary.Intent, highEntropySecret) {
+ t.Error("Summary.Intent should not contain the secret after redaction")
+ }
+ if !strings.Contains(content.Metadata.Summary.Intent, "REDACTED") {
+ t.Error("Summary.Intent should contain REDACTED placeholder")
+ }
+ if strings.Contains(content.Metadata.Summary.Outcome, highEntropySecret) {
+ t.Error("Summary.Outcome should not contain the secret after redaction")
+ }
+}
diff --git a/cli/checkpoint/checkpoint_6_test.go b/cli/checkpoint/checkpoint_6_test.go
new file mode 100644
index 0000000..78272f5
--- /dev/null
+++ b/cli/checkpoint/checkpoint_6_test.go
@@ -0,0 +1,465 @@
+package checkpoint
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func TestUpdateSummary_RedactsSecrets(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeef8")
+
+ // First write a checkpoint without a summary
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "update-summary-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Now update the summary with a secret
+ err = store.UpdateSummary(context.Background(), checkpointID, &Summary{
+ Intent: "Rotated key " + highEntropySecret,
+ Outcome: "Done",
+ })
+ if err != nil {
+ t.Fatalf("UpdateSummary() error = %v", err)
+ }
+
+ content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
+ if err != nil {
+ t.Fatalf("ReadSessionContent() error = %v", err)
+ }
+
+ if content.Metadata.Summary == nil {
+ t.Fatal("Summary should not be nil after update")
+ }
+ if strings.Contains(content.Metadata.Summary.Intent, highEntropySecret) {
+ t.Error("Updated Summary.Intent should not contain the secret")
+ }
+ if !strings.Contains(content.Metadata.Summary.Intent, "REDACTED") {
+ t.Error("Updated Summary.Intent should contain REDACTED placeholder")
+ }
+}
+
+func TestWriteCommitted_SubagentTranscript_JSONLFallback(t *testing.T) {
+ t.Parallel()
+ repo, _ := setupBranchTestRepo(t)
+ store := NewGitStore(repo)
+ checkpointID := id.MustCheckpointID("aabbccddeef9")
+
+ // Create a temp file with invalid JSONL containing a secret
+ tmpDir := t.TempDir()
+ transcriptPath := filepath.Join(tmpDir, "agent.jsonl")
+ invalidJSONL := "this is not valid JSON but has a secret " + highEntropySecret + " in it"
+ if err := os.WriteFile(transcriptPath, []byte(invalidJSONL), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
+ CheckpointID: checkpointID,
+ SessionID: "jsonl-fallback-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
+ CheckpointsCount: 1,
+ AuthorName: "Test Author",
+ AuthorEmail: "test@example.com",
+ IsTask: true,
+ ToolUseID: "toolu_test123",
+ AgentID: "agent1",
+ SubagentTranscriptPath: transcriptPath,
+ })
+ if err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Read back the subagent transcript from the tree
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get branch ref: %v", err)
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ agentPath := checkpointID.Path() + "/tasks/toolu_test123/agent-agent1.jsonl"
+ file, err := tree.File(agentPath)
+ if err != nil {
+ t.Fatalf("subagent transcript should exist at %s (JSONL fallback should not drop it): %v", agentPath, err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("failed to read subagent transcript: %v", err)
+ }
+
+ // Verify the transcript was stored (not dropped) and secret was redacted
+ if content == "" {
+ t.Error("subagent transcript should not be empty")
+ }
+ if strings.Contains(content, highEntropySecret) {
+ t.Error("subagent transcript should not contain the secret after fallback redaction")
+ }
+ if !strings.Contains(content, "REDACTED") {
+ t.Error("subagent transcript should contain REDACTED from fallback redaction")
+ }
+}
+
+func TestWriteTemporaryTask_SubagentTranscript_RedactsSecrets(t *testing.T) {
+ // Cannot use t.Parallel() because t.Chdir is required for paths.WorktreeRoot()
+ tempDir := t.TempDir()
+
+ // Initialize a git repository with an initial commit
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ readmeFile := filepath.Join(tempDir, "README.md")
+ if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
+ t.Fatalf("failed to write README: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to add README: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ // Create a temp file with invalid JSONL containing a secret
+ transcriptPath := filepath.Join(tempDir, "agent-transcript.jsonl")
+ invalidJSONL := "this is not valid JSON but has a secret " + highEntropySecret + " in it"
+ if err := os.WriteFile(transcriptPath, []byte(invalidJSONL), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ _, err = store.WriteTemporaryTask(context.Background(), WriteTemporaryTaskOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ToolUseID: "toolu_test456",
+ AgentID: "agent1",
+ SubagentTranscriptPath: transcriptPath,
+ CheckpointUUID: "test-uuid",
+ CommitMessage: "Task checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporaryTask() error = %v", err)
+ }
+
+ // Find the shadow branch and read the subagent transcript
+ shadowBranch := ShadowBranchNameForCommit(baseCommit, "")
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ if err != nil {
+ t.Fatalf("failed to get shadow branch ref: %v", err)
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ agentPath := paths.TraceMetadataDir + "/test-session/tasks/toolu_test456/agent-agent1.jsonl"
+ file, err := tree.File(agentPath)
+ if err != nil {
+ t.Fatalf("subagent transcript should exist at %s: %v", agentPath, err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("failed to read subagent transcript: %v", err)
+ }
+
+ // Verify the transcript was stored (not dropped) and secret was redacted
+ if content == "" {
+ t.Error("subagent transcript should not be empty")
+ }
+ if strings.Contains(content, highEntropySecret) {
+ t.Error("subagent transcript on shadow branch should not contain the secret after redaction")
+ }
+ if !strings.Contains(content, "REDACTED") {
+ t.Error("subagent transcript on shadow branch should contain REDACTED")
+ }
+}
+
+func TestAddDirectoryToEntries_PathTraversal(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create a directory structure where the relative path could escape
+ metadataDir := filepath.Join(tempDir, "metadata")
+ subDir := filepath.Join(metadataDir, "sub")
+ if err := os.MkdirAll(subDir, 0o755); err != nil {
+ t.Fatalf("failed to create dirs: %v", err)
+ }
+
+ // Create a regular file — should be included
+ regularFile := filepath.Join(subDir, "data.txt")
+ if err := os.WriteFile(regularFile, []byte("safe content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ entries := make(map[string]object.TreeEntry)
+ err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, ".trace/metadata/session", entries)
+ if err != nil {
+ t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
+ }
+
+ // Verify the regular file was included with correct path
+ expectedPath := filepath.ToSlash(filepath.Join(".trace/metadata/session", "sub", "data.txt"))
+ if _, ok := entries[expectedPath]; !ok {
+ t.Errorf("expected entry at %q, got entries: %v", expectedPath, entries)
+ }
+}
+
+func TestAddDirectoryToEntries_SkipsSymlinks(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create metadata directory
+ metadataDir := filepath.Join(tempDir, "metadata")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ // Create a regular file
+ regularFile := filepath.Join(metadataDir, "regular.txt")
+ if err := os.WriteFile(regularFile, []byte("regular content"), 0o644); err != nil {
+ t.Fatalf("failed to create regular file: %v", err)
+ }
+
+ // Create a sensitive file outside the metadata directory
+ sensitiveFile := filepath.Join(tempDir, "sensitive.txt")
+ if err := os.WriteFile(sensitiveFile, []byte("SECRET DATA"), 0o644); err != nil {
+ t.Fatalf("failed to create sensitive file: %v", err)
+ }
+
+ // Create a symlink inside metadata directory pointing to the sensitive file
+ symlinkPath := filepath.Join(metadataDir, "sneaky-link")
+ if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
+ t.Fatalf("failed to create symlink: %v", err)
+ }
+
+ entries := make(map[string]object.TreeEntry)
+ err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, "checkpoint/", entries)
+ if err != nil {
+ t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
+ }
+
+ // Verify regular file was included
+ if _, ok := entries["checkpoint/regular.txt"]; !ok {
+ t.Error("regular.txt should be included in entries")
+ }
+
+ // Verify symlink was NOT included
+ if _, ok := entries["checkpoint/sneaky-link"]; ok {
+ t.Error("symlink should NOT be included in entries — this would allow reading files outside the metadata directory")
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("expected 1 entry, got %d", len(entries))
+ }
+}
+
+func TestAddDirectoryToEntries_SkipsSymlinkedDirectories(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create metadata directory with a regular file
+ metadataDir := filepath.Join(tempDir, "metadata")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ regularFile := filepath.Join(metadataDir, "regular.txt")
+ if err := os.WriteFile(regularFile, []byte("regular content"), 0o644); err != nil {
+ t.Fatalf("failed to create regular file: %v", err)
+ }
+
+ // Create an external directory with sensitive files
+ externalDir := filepath.Join(tempDir, "external-secrets")
+ if err := os.MkdirAll(externalDir, 0o755); err != nil {
+ t.Fatalf("failed to create external dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(externalDir, "secret.txt"), []byte("SECRET DATA"), 0o644); err != nil {
+ t.Fatalf("failed to create secret file: %v", err)
+ }
+
+ // Create a symlink to the external directory inside metadata
+ symlinkDir := filepath.Join(metadataDir, "evil-dir-link")
+ if err := os.Symlink(externalDir, symlinkDir); err != nil {
+ t.Fatalf("failed to create directory symlink: %v", err)
+ }
+
+ entries := make(map[string]object.TreeEntry)
+ err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, "checkpoint/", entries)
+ if err != nil {
+ t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
+ }
+
+ // Verify regular file was included
+ if _, ok := entries["checkpoint/regular.txt"]; !ok {
+ t.Error("regular.txt should be included in entries")
+ }
+
+ // Verify files from the symlinked directory were NOT included
+ if _, ok := entries["checkpoint/evil-dir-link/secret.txt"]; ok {
+ t.Error("files inside symlinked directory should NOT be included — this would allow reading files outside the metadata directory")
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("expected 1 entry (regular.txt only), got %d: %v", len(entries), entries)
+ }
+}
+
+// TestWriteTemporaryTask_ExcludesGitIgnoredFiles verifies that task (subagent)
+// checkpoints also filter out gitignored files. This is the same vulnerability as
+// the WriteTemporary path — a subagent that touches .env must not leak it into the
+// shadow branch.
+func TestWriteTemporaryTask_ExcludesGitIgnoredFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ repo, err := git.PlainInit(tempDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create .gitignore that ignores .env
+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(".env\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .gitignore: %v", err)
+ }
+ if _, err := worktree.Add(".gitignore"); err != nil {
+ t.Fatalf("failed to add .gitignore: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write main.go: %v", err)
+ }
+ if _, err := worktree.Add("main.go"); err != nil {
+ t.Fatalf("failed to add main.go: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // Create gitignored .env file and a legitimate file on disk
+ if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("API_KEY=sk-secret-1234\n"), 0o644); err != nil {
+ t.Fatalf("failed to write .env: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tempDir, "handler.go"), []byte("package main\n\nfunc handler() {}\n"), 0o644); err != nil {
+ t.Fatalf("failed to write handler.go: %v", err)
+ }
+
+ t.Chdir(tempDir)
+
+ // Create subagent transcript file
+ transcriptPath := filepath.Join(tempDir, "agent-transcript.jsonl")
+ if err := os.WriteFile(transcriptPath, []byte(`{"role":"assistant","content":"done"}`+"\n"), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ store := NewGitStore(repo)
+ baseCommit := initialCommit.String()
+
+ // Write task checkpoint where subagent reports .env as modified
+ commitHash, err := store.WriteTemporaryTask(context.Background(), WriteTemporaryTaskOptions{
+ SessionID: "test-session",
+ BaseCommit: baseCommit,
+ ToolUseID: "toolu_test789",
+ AgentID: "agent1",
+ ModifiedFiles: []string{"handler.go", ".env"}, // Subagent reports both
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ SubagentTranscriptPath: transcriptPath,
+ CheckpointUUID: "test-uuid",
+ CommitMessage: "Task checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporaryTask() error = %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // handler.go SHOULD be in the tree
+ _, err = tree.File("handler.go")
+ if err != nil {
+ t.Errorf("handler.go should be in task checkpoint tree: %v", err)
+ }
+
+ // .env MUST NOT be in the tree
+ _, err = tree.File(".env")
+ if err == nil {
+ t.Error("SECURITY: gitignored file .env leaked into task checkpoint tree — secrets exposed on shadow branch via subagent")
+ }
+}
diff --git a/cli/checkpoint/checkpoint_test.go b/cli/checkpoint/checkpoint_test.go
index 423952e..c795f83 100644
--- a/cli/checkpoint/checkpoint_test.go
+++ b/cli/checkpoint/checkpoint_test.go
@@ -3,28 +3,20 @@ package checkpoint
import (
"context"
"encoding/json"
- "errors"
- "fmt"
"os"
- "os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
- "time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/testutil"
"github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/cli/vercelconfig"
- "github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/GrayCodeAI/trace/redact"
- "github.com/stretchr/testify/require"
"github.com/go-git/go-git/v6"
- "github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
@@ -815,3479 +807,3 @@ func TestUpdateSummary(t *testing.T) {
t.Errorf("metadata.FilesTouched length = %d, want 2", len(updatedMetadata.FilesTouched))
}
}
-
-// TestUpdateSummary_NotFound verifies that UpdateSummary returns an error
-// when the checkpoint doesn't exist.
-func TestUpdateSummary_NotFound(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
-
- // Ensure sessions branch exists
- err := store.ensureSessionsBranch(context.Background())
- if err != nil {
- t.Fatalf("ensureSessionsBranch() error = %v", err)
- }
-
- // Try to update a non-existent checkpoint (ID must be 12 hex chars)
- checkpointID := id.MustCheckpointID("000000000000")
- summary := &Summary{Intent: "Test", Outcome: "Test"}
-
- err = store.UpdateSummary(context.Background(), checkpointID, summary)
- if err == nil {
- t.Error("UpdateSummary() should return error for non-existent checkpoint")
- }
- if !errors.Is(err, ErrCheckpointNotFound) {
- t.Errorf("UpdateSummary() error = %v, want ErrCheckpointNotFound", err)
- }
-}
-
-// TestListCommitted_FallsBackToRemote verifies that ListCommitted can find
-// checkpoints when only origin/trace/checkpoints/v1 exists (simulating post-clone state).
-func TestListCommitted_FallsBackToRemote(t *testing.T) {
- // Create "remote" repo (non-bare, so we can make commits)
- remoteDir := t.TempDir()
- remoteRepo, err := git.PlainInit(remoteDir, false)
- if err != nil {
- t.Fatalf("failed to init remote repo: %v", err)
- }
-
- // Create an initial commit on main branch (required for cloning)
- remoteWorktree, err := remoteRepo.Worktree()
- if err != nil {
- t.Fatalf("failed to get remote worktree: %v", err)
- }
- readmeFile := filepath.Join(remoteDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := remoteWorktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- if _, err := remoteWorktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- }); err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create trace/checkpoints/v1 branch on the remote with a checkpoint
- remoteStore := NewGitStore(remoteRepo)
- cpID := id.MustCheckpointID("abcdef123456")
- err = remoteStore.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session-id",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"test": true}`)),
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("failed to write checkpoint to remote: %v", err)
- }
-
- // Clone the repo (this clones main, but not trace/checkpoints/v1 by default)
- localDir := t.TempDir()
- localRepo, err := git.PlainClone(localDir, &git.CloneOptions{
- URL: remoteDir,
- })
- if err != nil {
- t.Fatalf("failed to clone repo: %v", err)
- }
-
- // Fetch the trace/checkpoints/v1 branch to origin/trace/checkpoints/v1
- // (but don't create local branch - simulating post-clone state)
- refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", paths.MetadataBranchName, paths.MetadataBranchName)
- err = localRepo.Fetch(&git.FetchOptions{
- RemoteName: "origin",
- RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
- })
- if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
- t.Fatalf("failed to fetch trace/checkpoints/v1: %v", err)
- }
-
- // Verify local branch doesn't exist
- _, err = localRepo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err == nil {
- t.Fatal("local trace/checkpoints/v1 branch should not exist")
- }
-
- // Verify remote-tracking branch exists
- _, err = localRepo.Reference(plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("origin/trace/checkpoints/v1 should exist: %v", err)
- }
-
- // ListCommitted should find the checkpoint by falling back to remote
- localStore := NewGitStore(localRepo)
- checkpoints, err := localStore.ListCommitted(context.Background())
- if err != nil {
- t.Fatalf("ListCommitted() error = %v", err)
- }
- if len(checkpoints) != 1 {
- t.Errorf("ListCommitted() returned %d checkpoints, want 1", len(checkpoints))
- }
- if len(checkpoints) > 0 && checkpoints[0].CheckpointID.String() != cpID.String() {
- t.Errorf("ListCommitted() checkpoint ID = %q, want %q", checkpoints[0].CheckpointID, cpID)
- }
-}
-
-// TestGetCheckpointAuthor verifies that GetCheckpointAuthor retrieves the
-// author of the commit that created the checkpoint on the trace/checkpoints/v1 branch.
-func TestGetCheckpointAuthor(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
-
- // Create a checkpoint with specific author info
- authorName := "Alice Developer"
- authorEmail := "alice@example.com"
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "test-session-author",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("test transcript")),
- FilesTouched: []string{"main.go"},
- AuthorName: authorName,
- AuthorEmail: authorEmail,
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Retrieve the author
- author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("GetCheckpointAuthor() error = %v", err)
- }
-
- if author.Name != authorName {
- t.Errorf("author.Name = %q, want %q", author.Name, authorName)
- }
- if author.Email != authorEmail {
- t.Errorf("author.Email = %q, want %q", author.Email, authorEmail)
- }
-}
-
-// TestGetCheckpointAuthor_NotFound verifies that GetCheckpointAuthor returns
-// empty author when the checkpoint doesn't exist.
-func TestGetCheckpointAuthor_NotFound(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
-
- // Query for a non-existent checkpoint (must be valid hex)
- checkpointID := id.MustCheckpointID("ffffffffffff")
-
- author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("GetCheckpointAuthor() error = %v", err)
- }
-
- // Should return empty author (no error)
- if author.Name != "" || author.Email != "" {
- t.Errorf("expected empty author for non-existent checkpoint, got Name=%q, Email=%q", author.Name, author.Email)
- }
-}
-
-// TestGetCheckpointAuthor_NoSessionsBranch verifies that GetCheckpointAuthor
-// returns empty author when the trace/checkpoints/v1 branch doesn't exist.
-func TestGetCheckpointAuthor_NoSessionsBranch(t *testing.T) {
- // Create a fresh repo without sessions branch
- tempDir := t.TempDir()
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeeff")
-
- author, err := store.GetCheckpointAuthor(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("GetCheckpointAuthor() error = %v", err)
- }
-
- // Should return empty author (no error)
- if author.Name != "" || author.Email != "" {
- t.Errorf("expected empty author when sessions branch doesn't exist, got Name=%q, Email=%q", author.Name, author.Email)
- }
-}
-
-// =============================================================================
-// Multi-Session Tests - Tests for checkpoint structure with CheckpointSummary
-// at root level and sessions stored in numbered subfolders (0-based: 0/, 1/, 2/)
-// =============================================================================
-
-// TestWriteCommitted_MultipleSessionsSameCheckpoint verifies that writing multiple
-// sessions to the same checkpoint ID creates separate numbered subdirectories.
-func TestWriteCommitted_MultipleSessionsSameCheckpoint(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("a1a2a3a4a5a6")
-
- // Write first session
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-one",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "first session"}`)),
- Prompts: []string{"First prompt"},
- FilesTouched: []string{"file1.go"},
- CheckpointsCount: 3,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() first session error = %v", err)
- }
-
- // Write second session to the same checkpoint ID
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-two",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "second session"}`)),
- Prompts: []string{"Second prompt"},
- FilesTouched: []string{"file2.go"},
- CheckpointsCount: 2,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() second session error = %v", err)
- }
-
- // Read the checkpoint summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
- if summary == nil {
- t.Fatal("ReadCommitted() returned nil summary")
- return
- }
-
- // Verify Sessions array has 2 entries
- if len(summary.Sessions) != 2 {
- t.Errorf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
- }
-
- // Verify both sessions have correct file paths (0-based indexing)
- if !strings.Contains(summary.Sessions[0].Transcript, "/0/") {
- t.Errorf("session 0 transcript path should contain '/0/', got %s", summary.Sessions[0].Transcript)
- }
- if !strings.Contains(summary.Sessions[1].Transcript, "/1/") {
- t.Errorf("session 1 transcript path should contain '/1/', got %s", summary.Sessions[1].Transcript)
- }
-
- // Verify session content can be read from each subdirectory
- content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if content0.Metadata.SessionID != "session-one" {
- t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-one")
- }
-
- content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
- if err != nil {
- t.Fatalf("ReadSessionContent(1) error = %v", err)
- }
- if content1.Metadata.SessionID != "session-two" {
- t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-two")
- }
-}
-
-// TestWriteCommitted_Aggregation verifies that CheckpointSummary correctly
-// aggregates statistics (CheckpointsCount, FilesTouched, TokenUsage) from
-// multiple sessions written to the same checkpoint.
-func TestWriteCommitted_Aggregation(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("b1b2b3b4b5b6")
-
- // Write first session with specific stats
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-one",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "first"}`)),
- FilesTouched: []string{"a.go", "b.go"},
- CheckpointsCount: 3,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- APICallCount: 5,
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() first session error = %v", err)
- }
-
- // Write second session with overlapping and new files
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-two",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "second"}`)),
- FilesTouched: []string{"b.go", "c.go"}, // b.go overlaps
- CheckpointsCount: 2,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 50,
- OutputTokens: 25,
- APICallCount: 3,
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() second session error = %v", err)
- }
-
- // Read the checkpoint summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
- if summary == nil {
- t.Fatal("ReadCommitted() returned nil summary")
- return
- }
-
- // Verify aggregated CheckpointsCount = 3 + 2 = 5
- if summary.CheckpointsCount != 5 {
- t.Errorf("summary.CheckpointsCount = %d, want 5", summary.CheckpointsCount)
- }
-
- // Verify merged FilesTouched = ["a.go", "b.go", "c.go"] (sorted, deduplicated)
- expectedFiles := []string{"a.go", "b.go", "c.go"}
- if len(summary.FilesTouched) != len(expectedFiles) {
- t.Errorf("len(summary.FilesTouched) = %d, want %d", len(summary.FilesTouched), len(expectedFiles))
- }
- for i, want := range expectedFiles {
- if i >= len(summary.FilesTouched) {
- break
- }
- if summary.FilesTouched[i] != want {
- t.Errorf("summary.FilesTouched[%d] = %q, want %q", i, summary.FilesTouched[i], want)
- }
- }
-
- // Verify aggregated TokenUsage
- if summary.TokenUsage == nil {
- t.Fatal("summary.TokenUsage should not be nil")
- }
- if summary.TokenUsage.InputTokens != 150 {
- t.Errorf("summary.TokenUsage.InputTokens = %d, want 150", summary.TokenUsage.InputTokens)
- }
- if summary.TokenUsage.OutputTokens != 75 {
- t.Errorf("summary.TokenUsage.OutputTokens = %d, want 75", summary.TokenUsage.OutputTokens)
- }
- if summary.TokenUsage.APICallCount != 8 {
- t.Errorf("summary.TokenUsage.APICallCount = %d, want 8", summary.TokenUsage.APICallCount)
- }
-}
-
-// TestReadCommitted_ReturnsCheckpointSummary verifies that ReadCommitted returns
-// a CheckpointSummary with the correct structure including Sessions array.
-func TestReadCommitted_ReturnsCheckpointSummary(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("c1c2c3c4c5c6")
-
- // Write two sessions
- for i, sessionID := range []string{"session-alpha", "session-beta"} {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: sessionID,
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session": %d}`, i))),
- Prompts: []string{fmt.Sprintf("Prompt %d", i)},
- FilesTouched: []string{fmt.Sprintf("file%d.go", i)},
- CheckpointsCount: i + 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %d error = %v", i, err)
- }
- }
-
- // Read the checkpoint summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
- if summary == nil {
- t.Fatal("ReadCommitted() returned nil summary")
- return
- }
-
- // Verify basic summary fields
- if summary.CheckpointID != checkpointID {
- t.Errorf("summary.CheckpointID = %v, want %v", summary.CheckpointID, checkpointID)
- }
- if summary.Strategy != "manual-commit" {
- t.Errorf("summary.Strategy = %q, want %q", summary.Strategy, "manual-commit")
- }
-
- // Verify Sessions array
- if len(summary.Sessions) != 2 {
- t.Fatalf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
- }
-
- // Verify file paths point to correct locations
- for i, session := range summary.Sessions {
- expectedSubdir := fmt.Sprintf("/%d/", i)
- if !strings.Contains(session.Metadata, expectedSubdir) {
- t.Errorf("session %d Metadata path should contain %q, got %q", i, expectedSubdir, session.Metadata)
- }
- if !strings.Contains(session.Transcript, expectedSubdir) {
- t.Errorf("session %d Transcript path should contain %q, got %q", i, expectedSubdir, session.Transcript)
- }
- }
-}
-
-// TestReadSessionContent_ByIndex verifies that ReadSessionContent can read
-// specific sessions by their 0-based index within a checkpoint.
-func TestReadSessionContent_ByIndex(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("d1d2d3d4d5d6")
-
- // Write two sessions with distinct content
- sessions := []struct {
- id string
- transcript string
- prompt string
- }{
- {"session-first", `{"order": "first"}`, "First user prompt"},
- {"session-second", `{"order": "second"}`, "Second user prompt"},
- }
-
- for _, s := range sessions {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: s.id,
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(s.transcript)),
- Prompts: []string{s.prompt},
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %s error = %v", s.id, err)
- }
- }
-
- // Read session 0
- content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if content0.Metadata.SessionID != "session-first" {
- t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-first")
- }
- if !strings.Contains(string(content0.Transcript), "first") {
- t.Errorf("session 0 transcript should contain 'first', got %s", string(content0.Transcript))
- }
- if !strings.Contains(content0.Prompts, "First") {
- t.Errorf("session 0 prompts should contain 'First', got %s", content0.Prompts)
- }
-
- // Read session 1
- content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
- if err != nil {
- t.Fatalf("ReadSessionContent(1) error = %v", err)
- }
- if content1.Metadata.SessionID != "session-second" {
- t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-second")
- }
- if !strings.Contains(string(content1.Transcript), "second") {
- t.Errorf("session 1 transcript should contain 'second', got %s", string(content1.Transcript))
- }
-}
-
-// writeSingleSession is a test helper that creates a store with a single session
-// and returns the store and checkpoint ID for further testing.
-func writeSingleSession(t *testing.T, cpIDStr, sessionID, transcript string) (*GitStore, id.CheckpointID) {
- t.Helper()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID(cpIDStr)
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: sessionID,
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(transcript)),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
- return store, checkpointID
-}
-
-func TestWriteCommitted_CodexSanitizesPortableTranscript(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("c0de1234beef")
-
- transcript := `{"timestamp":"2026-03-25T11:31:11.754Z","type":"response_item","payload":{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"}}
-{"timestamp":"2026-03-25T11:31:11.755Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"REDACTED"}}
-{"timestamp":"2026-03-25T11:31:11.756Z","type":"compacted","payload":{"message":"","replacement_history":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"reasoning","summary":[{"text":"nested"}],"encrypted_content":"REDACTED"},{"type":"compaction","encrypted_content":"REDACTED"},{"type":"compaction_summary","encrypted_content":"REDACTED"}]}}
-`
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "codex-session",
- Strategy: "manual-commit",
- Agent: agent.AgentTypeCodex,
- Transcript: redact.AlreadyRedacted([]byte(transcript)),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- require.NoError(t, err)
-
- content, err := store.ReadLatestSessionContent(context.Background(), checkpointID)
- require.NoError(t, err)
-
- got := string(content.Transcript)
- require.NotContains(t, got, `"encrypted_content":"REDACTED"`)
- require.NotContains(t, got, `"type":"compaction"`)
- require.NotContains(t, got, `"type":"compaction_summary"`)
- require.Contains(t, got, `"summary":[{"text":"brief"}]`)
- require.Contains(t, got, `"summary":[{"text":"nested"}]`)
-}
-
-// TestReadSessionContent_InvalidIndex verifies that ReadSessionContent returns
-// an error when requesting a session index that doesn't exist.
-func TestReadSessionContent_InvalidIndex(t *testing.T) {
- store, checkpointID := writeSingleSession(t, "e1e2e3e4e5e6", "only-session", `{"single": true}`)
-
- // Try to read session index 1 (doesn't exist)
- _, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
- if err == nil {
- t.Error("ReadSessionContent(1) should return error for non-existent session")
- }
- if !strings.Contains(err.Error(), "session 1 not found") {
- t.Errorf("error should mention session not found, got: %v", err)
- }
- if !errors.Is(err, ErrCheckpointNotFound) {
- t.Errorf("ReadSessionContent(1) error = %v, want ErrCheckpointNotFound", err)
- }
-}
-
-// TestReadLatestSessionContent verifies that ReadLatestSessionContent returns
-// the content of the most recently added session (highest index).
-func TestReadLatestSessionContent(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("f1f2f3f4f5f6")
-
- // Write three sessions
- for i := range 3 {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: fmt.Sprintf("session-%d", i),
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"index": %d}`, i))),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %d error = %v", i, err)
- }
- }
-
- // Read latest session content
- content, err := store.ReadLatestSessionContent(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadLatestSessionContent() error = %v", err)
- }
-
- // Should return session 2 (0-indexed, so latest is index 2)
- if content.Metadata.SessionID != "session-2" {
- t.Errorf("latest session SessionID = %q, want %q", content.Metadata.SessionID, "session-2")
- }
- if !strings.Contains(string(content.Transcript), `"index": 2`) {
- t.Errorf("latest session transcript should contain index 2, got %s", string(content.Transcript))
- }
-}
-
-// TestReadSessionContentByID verifies that ReadSessionContentByID can find
-// a session by its session ID rather than by index.
-func TestReadSessionContentByID(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("010203040506")
-
- // Write two sessions with distinct IDs
- sessionIDs := []string{"unique-id-alpha", "unique-id-beta"}
- for i, sid := range sessionIDs {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: sid,
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session_name": "%s"}`, sid))),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %d error = %v", i, err)
- }
- }
-
- // Read by session ID
- content, err := store.ReadSessionContentByID(context.Background(), checkpointID, "unique-id-beta")
- if err != nil {
- t.Fatalf("ReadSessionContentByID() error = %v", err)
- }
-
- if content.Metadata.SessionID != "unique-id-beta" {
- t.Errorf("SessionID = %q, want %q", content.Metadata.SessionID, "unique-id-beta")
- }
- if !strings.Contains(string(content.Transcript), "unique-id-beta") {
- t.Errorf("transcript should contain session name, got %s", string(content.Transcript))
- }
-}
-
-// TestReadSessionContentByID_NotFound verifies that ReadSessionContentByID
-// returns an error when the session ID doesn't exist in the checkpoint.
-func TestReadSessionContentByID_NotFound(t *testing.T) {
- store, checkpointID := writeSingleSession(t, "111213141516", "existing-session", `{"exists": true}`)
-
- // Try to read non-existent session ID
- _, err := store.ReadSessionContentByID(context.Background(), checkpointID, "nonexistent-session")
- if err == nil {
- t.Error("ReadSessionContentByID() should return error for non-existent session ID")
- }
- if !strings.Contains(err.Error(), "not found") {
- t.Errorf("error should mention 'not found', got: %v", err)
- }
-}
-
-// TestListCommitted_MultiSessionInfo verifies that ListCommitted returns correct
-// information for checkpoints with multiple sessions.
-func TestListCommitted_MultiSessionInfo(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("212223242526")
-
- // Write two sessions to the same checkpoint
- for i, sid := range []string{"list-session-1", "list-session-2"} {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: sid,
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"i": %d}`, i))),
- FilesTouched: []string{fmt.Sprintf("file%d.go", i)},
- CheckpointsCount: i + 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %d error = %v", i, err)
- }
- }
-
- // List all checkpoints
- checkpoints, err := store.ListCommitted(context.Background())
- if err != nil {
- t.Fatalf("ListCommitted() error = %v", err)
- }
-
- // Find our checkpoint
- var found *CommittedInfo
- for i := range checkpoints {
- if checkpoints[i].CheckpointID == checkpointID {
- found = &checkpoints[i]
- break
- }
- }
- if found == nil {
- t.Fatal("checkpoint not found in ListCommitted() results")
- return
- }
-
- // Verify SessionCount = 2
- if found.SessionCount != 2 {
- t.Errorf("SessionCount = %d, want 2", found.SessionCount)
- }
-
- // Verify SessionID is from the latest session
- if found.SessionID != "list-session-2" {
- t.Errorf("SessionID = %q, want %q (latest session)", found.SessionID, "list-session-2")
- }
-
- // Verify Agent comes from latest session metadata
- if found.Agent != agent.AgentTypeClaudeCode {
- t.Errorf("Agent = %q, want %q", found.Agent, agent.AgentTypeClaudeCode)
- }
-}
-
-// TestWriteCommitted_SessionWithNoPrompts verifies that a session can be
-// written without prompts and still be read correctly.
-func TestWriteCommitted_SessionWithNoPrompts(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("313233343536")
-
- // Write session without prompts
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "no-prompts-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"no_prompts": true}`)),
- Prompts: nil, // No prompts
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Read the session content
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- // Verify session metadata is correct
- if content.Metadata.SessionID != "no-prompts-session" {
- t.Errorf("SessionID = %q, want %q", content.Metadata.SessionID, "no-prompts-session")
- }
-
- // Verify transcript is present
- if len(content.Transcript) == 0 {
- t.Error("Transcript should not be empty")
- }
-
- // Verify prompts is empty
- if content.Prompts != "" {
- t.Errorf("Prompts should be empty, got %q", content.Prompts)
- }
-}
-
-// TestWriteCommitted_SessionWithSummary verifies that a non-nil Summary
-// in WriteCommittedOptions is persisted in the session-level metadata.json.
-// Regression test for ENT-243 where Summary was omitted from the struct literal.
-func TestWriteCommitted_SessionWithSummary(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeeff")
-
- summary := &Summary{
- Intent: "User wanted to fix a bug",
- Outcome: "Bug was fixed",
- }
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "summary-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"test": true}`)),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- Summary: summary,
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- if content.Metadata.Summary == nil {
- t.Fatal("Summary should not be nil")
- }
- if content.Metadata.Summary.Intent != "User wanted to fix a bug" {
- t.Errorf("Summary.Intent = %q, want %q", content.Metadata.Summary.Intent, "User wanted to fix a bug")
- }
- if content.Metadata.Summary.Outcome != "Bug was fixed" {
- t.Errorf("Summary.Outcome = %q, want %q", content.Metadata.Summary.Outcome, "Bug was fixed")
- }
-}
-
-// TestWriteCommitted_ThreeSessions verifies the structure with three sessions
-// to ensure the 0-based indexing works correctly throughout.
-func TestWriteCommitted_ThreeSessions(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("515253545556")
-
- // Write three sessions
- for i := range 3 {
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: fmt.Sprintf("three-session-%d", i),
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"session_number": %d}`, i))),
- FilesTouched: []string{fmt.Sprintf("s%d.go", i)},
- CheckpointsCount: i + 1,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 100 * (i + 1),
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session %d error = %v", i, err)
- }
- }
-
- // Read summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
-
- // Verify 3 sessions
- if len(summary.Sessions) != 3 {
- t.Errorf("len(summary.Sessions) = %d, want 3", len(summary.Sessions))
- }
-
- // Verify aggregated stats
- // CheckpointsCount = 1 + 2 + 3 = 6
- if summary.CheckpointsCount != 6 {
- t.Errorf("summary.CheckpointsCount = %d, want 6", summary.CheckpointsCount)
- }
-
- // FilesTouched = [s0.go, s1.go, s2.go]
- if len(summary.FilesTouched) != 3 {
- t.Errorf("len(summary.FilesTouched) = %d, want 3", len(summary.FilesTouched))
- }
-
- // TokenUsage.InputTokens = 100 + 200 + 300 = 600
- if summary.TokenUsage == nil {
- t.Fatal("summary.TokenUsage should not be nil")
- }
- if summary.TokenUsage.InputTokens != 600 {
- t.Errorf("summary.TokenUsage.InputTokens = %d, want 600", summary.TokenUsage.InputTokens)
- }
-
- // Verify each session can be read by index
- for i := range 3 {
- content, err := store.ReadSessionContent(context.Background(), checkpointID, i)
- if err != nil {
- t.Errorf("ReadSessionContent(%d) error = %v", i, err)
- continue
- }
- expectedID := fmt.Sprintf("three-session-%d", i)
- if content.Metadata.SessionID != expectedID {
- t.Errorf("session %d SessionID = %q, want %q", i, content.Metadata.SessionID, expectedID)
- }
- }
-}
-
-// TestReadCommitted_NonexistentCheckpoint verifies that ReadCommitted returns
-// nil (not an error) when the checkpoint doesn't exist.
-func TestReadCommitted_NonexistentCheckpoint(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
-
- // Ensure sessions branch exists
- err := store.ensureSessionsBranch(context.Background())
- if err != nil {
- t.Fatalf("ensureSessionsBranch() error = %v", err)
- }
-
- // Try to read non-existent checkpoint
- checkpointID := id.MustCheckpointID("ffffffffffff")
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Errorf("ReadCommitted() error = %v, want nil", err)
- }
- if summary != nil {
- t.Errorf("ReadCommitted() = %v, want nil for non-existent checkpoint", summary)
- }
-}
-
-// TestReadSessionContent_NonexistentCheckpoint verifies that ReadSessionContent
-// returns ErrCheckpointNotFound when the checkpoint doesn't exist.
-func TestReadSessionContent_NonexistentCheckpoint(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
-
- // Ensure sessions branch exists
- err := store.ensureSessionsBranch(context.Background())
- if err != nil {
- t.Fatalf("ensureSessionsBranch() error = %v", err)
- }
-
- // Try to read from non-existent checkpoint
- checkpointID := id.MustCheckpointID("eeeeeeeeeeee")
- _, err = store.ReadSessionContent(context.Background(), checkpointID, 0)
- if !errors.Is(err, ErrCheckpointNotFound) {
- t.Errorf("ReadSessionContent() error = %v, want ErrCheckpointNotFound", err)
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_CapturesModifiedTrackedFiles verifies that
-// the first checkpoint captures modifications to tracked files that existed before
-// the agent made any changes (user's uncommitted work).
-func TestWriteTemporary_FirstCheckpoint_CapturesModifiedTrackedFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit containing README.md
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit README.md with original content
- readmeFile := filepath.Join(tempDir, "README.md")
- originalContent := "# Original Content\n"
- if err := os.WriteFile(readmeFile, []byte(originalContent), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // Simulate user modifying README.md BEFORE agent starts (user's uncommitted work)
- modifiedContent := "# Modified by User\n\nThis change was made before the agent started.\n"
- if err := os.WriteFile(readmeFile, []byte(modifiedContent), 0o644); err != nil {
- t.Fatalf("failed to modify README: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- // Note: ModifiedFiles is empty because agent hasn't touched anything yet
- // The first checkpoint should still capture README.md because it's modified in working dir
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{}, // Agent hasn't modified anything
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
- if result.Skipped {
- t.Error("first checkpoint should not be skipped")
- }
-
- // Verify the shadow branch commit contains the MODIFIED README.md content
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // Find README.md in the tree
- file, err := tree.File("README.md")
- if err != nil {
- t.Fatalf("README.md not found in checkpoint tree: %v", err)
- }
-
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("failed to read README.md content: %v", err)
- }
-
- if content != modifiedContent {
- t.Errorf("checkpoint should contain modified content\ngot:\n%s\nwant:\n%s", content, modifiedContent)
- }
-}
-
-// TestWriteTemporary_PathNormalizationAndSkipping verifies that shadow branch writes
-// normalize absolute in-repo paths back to repo-relative tree entries and skip invalid
-// paths rather than encoding them into git trees.
-func TestWriteTemporary_PathNormalizationAndSkipping(t *testing.T) {
- tests := []struct {
- name string
- modifiedFiles func(repoRoot, mainFile string) []string
- wantUpdated bool
- }{
- {
- name: "absolute in repo path is normalized",
- modifiedFiles: func(_, mainFile string) []string {
- return []string{mainFile}
- },
- wantUpdated: true,
- },
- {
- name: "absolute outside repo path is skipped",
- modifiedFiles: func(_, _ string) []string {
- return []string{"C:/Users/rober/Vaults/Flowsign/main.go"}
- },
- wantUpdated: false,
- },
- {
- name: "empty segment path is skipped",
- modifiedFiles: func(_, _ string) []string {
- return []string{"dir//main.go"}
- },
- wantUpdated: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- tempDir := t.TempDir()
- // Resolve symlinks so absolute paths match git's resolved repo root.
- // On macOS, t.TempDir() returns /var/... but git resolves to /private/var/...
- tempDir, err := filepath.EvalSymlinks(tempDir)
- if err != nil {
- t.Fatalf("failed to resolve symlinks: %v", err)
- }
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- mainFile := filepath.Join(tempDir, "main.go")
- if err := os.WriteFile(mainFile, []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write main.go: %v", err)
- }
- if _, err := worktree.Add("main.go"); err != nil {
- t.Fatalf("failed to add main.go: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- updatedContent := "package main\n\nfunc main() {}\n"
- if err := os.WriteFile(mainFile, []byte(updatedContent), 0o644); err != nil {
- t.Fatalf("failed to update main.go: %v", err)
- }
-
- t.Chdir(tempDir)
-
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: initialCommit.String(),
- ModifiedFiles: tt.modifiedFiles(tempDir, mainFile),
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "Checkpoint with path normalization",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- assertNoEmptyEntryNames(t, repo, commit.TreeHash, "")
-
- file, err := tree.File("main.go")
- if err != nil {
- t.Fatalf("main.go not found in checkpoint tree: %v", err)
- }
-
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("failed to read main.go content: %v", err)
- }
-
- wantContent := "package main\n"
- if tt.wantUpdated {
- wantContent = updatedContent
- }
- if content != wantContent {
- t.Errorf("unexpected main.go content\ngot:\n%s\nwant:\n%s", content, wantContent)
- }
- })
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles verifies that
-// the first checkpoint captures untracked files that exist in the working directory.
-func TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit README.md
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // Create an untracked file (simulating user creating a file before agent starts)
- untrackedFile := filepath.Join(tempDir, "config.local.json")
- untrackedContent := `{"key": "secret_value"}`
- if err := os.WriteFile(untrackedFile, []byte(untrackedContent), 0o644); err != nil {
- t.Fatalf("failed to write untracked file: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- NewFiles: []string{}, // NewFiles might be empty if this is truly "at session start"
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the shadow branch commit contains the untracked file
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // Find config.local.json in the tree
- file, err := tree.File("config.local.json")
- if err != nil {
- t.Fatalf("untracked file config.local.json not found in checkpoint tree: %v", err)
- }
-
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("failed to read config.local.json content: %v", err)
- }
-
- if content != untrackedContent {
- t.Errorf("checkpoint should contain untracked file content\ngot:\n%s\nwant:\n%s", content, untrackedContent)
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_ExcludesGitIgnoredFiles verifies that
-// the first checkpoint does NOT capture files that are in .gitignore.
-func TestWriteTemporary_FirstCheckpoint_ExcludesGitIgnoredFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create .gitignore that ignores node_modules/
- gitignoreFile := filepath.Join(tempDir, ".gitignore")
- if err := os.WriteFile(gitignoreFile, []byte("node_modules/\n"), 0o644); err != nil {
- t.Fatalf("failed to write .gitignore: %v", err)
- }
- if _, err := worktree.Add(".gitignore"); err != nil {
- t.Fatalf("failed to add .gitignore: %v", err)
- }
-
- // Create and commit README.md
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // Create node_modules/ directory with a file (should be ignored)
- nodeModulesDir := filepath.Join(tempDir, "node_modules")
- if err := os.MkdirAll(nodeModulesDir, 0o755); err != nil {
- t.Fatalf("failed to create node_modules: %v", err)
- }
- ignoredFile := filepath.Join(nodeModulesDir, "some-package.js")
- if err := os.WriteFile(ignoredFile, []byte("module.exports = {}"), 0o644); err != nil {
- t.Fatalf("failed to write ignored file: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the shadow branch commit does NOT contain node_modules/
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // node_modules/some-package.js should NOT be in the tree
- _, err = tree.File("node_modules/some-package.js")
- if err == nil {
- t.Error("gitignored file node_modules/some-package.js should NOT be in checkpoint tree")
- } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
- t.Fatalf("expected node_modules/some-package.js to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
- }
-}
-
-// TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredModifiedFiles verifies that
-// subsequent checkpoints (IsFirstCheckpoint=false) filter out gitignored files from
-// ModifiedFiles. This is a security-critical test: if an agent modifies a .env file
-// and reports it in its transcript, the .env file must NOT leak into the shadow branch.
-// See: https://techstackups.com/guides/trace-io-hands-on-what-it-actually-captures/#what-leaks-into-checkpoints
-func TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredModifiedFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- testutil.InitRepo(t, tempDir)
- repo, err := git.PlainOpen(tempDir)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create .gitignore that ignores .env files
- gitignoreContent := ".env\n*.secret\nnode_modules/\n"
- if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
- t.Fatalf("failed to write .gitignore: %v", err)
- }
- if _, err := worktree.Add(".gitignore"); err != nil {
- t.Fatalf("failed to add .gitignore: %v", err)
- }
-
- // Create and commit a tracked file
- if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write main.go: %v", err)
- }
- if _, err := worktree.Add("main.go"); err != nil {
- t.Fatalf("failed to add main.go: %v", err)
- }
- testutil.GitCommit(t, tempDir, "Initial commit")
- headRef, err := repo.Head()
- require.NoError(t, err)
- initialCommit := headRef.Hash()
-
- // Create gitignored files on disk (simulating an agent creating/modifying them)
- if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("API_KEY=sk-secret-1234\n"), 0o644); err != nil {
- t.Fatalf("failed to write .env: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tempDir, "db.secret"), []byte("password=hunter2\n"), 0o644); err != nil {
- t.Fatalf("failed to write db.secret: %v", err)
- }
-
- // Also modify a tracked file (this SHOULD be captured)
- if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
- t.Fatalf("failed to modify main.go: %v", err)
- }
-
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- // Write first checkpoint to establish the shadow branch
- firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("first WriteTemporary() error = %v", err)
- }
- require.False(t, firstResult.Skipped)
-
- // Now write a subsequent checkpoint where the agent reports .env and db.secret
- // as modified files (e.g., agent touched them during its turn).
- // These gitignored files must NOT appear in the checkpoint tree.
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{"main.go", ".env", "db.secret"}, // Agent reports these
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "Second checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: false,
- })
- if err != nil {
- t.Fatalf("second WriteTemporary() error = %v", err)
- }
-
- // Verify the checkpoint tree (use returned commit hash — works whether skipped or not)
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // main.go SHOULD be in the tree (tracked file, legitimately modified)
- _, err = tree.File("main.go")
- if err != nil {
- t.Errorf("main.go should be in checkpoint tree: %v", err)
- }
-
- // .env MUST NOT be in the tree (gitignored — contains API key)
- _, err = tree.File(".env")
- if err == nil {
- t.Error("SECURITY: gitignored file .env leaked into checkpoint tree — API keys exposed on shadow branch")
- }
-
- // db.secret MUST NOT be in the tree (gitignored)
- _, err = tree.File("db.secret")
- if err == nil {
- t.Error("SECURITY: gitignored file db.secret leaked into checkpoint tree — secrets exposed on shadow branch")
- }
-}
-
-// TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredNewFiles verifies that
-// subsequent checkpoints filter out gitignored files from NewFiles.
-func TestWriteTemporary_SubsequentCheckpoint_ExcludesGitIgnoredNewFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- testutil.InitRepo(t, tempDir)
- repo, err := git.PlainOpen(tempDir)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create .gitignore
- if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(".env\n"), 0o644); err != nil {
- t.Fatalf("failed to write .gitignore: %v", err)
- }
- if _, err := worktree.Add(".gitignore"); err != nil {
- t.Fatalf("failed to add .gitignore: %v", err)
- }
-
- if err := os.WriteFile(filepath.Join(tempDir, "README.md"), []byte("# Test\n"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- testutil.GitCommit(t, tempDir, "Initial commit")
- headRef, err := repo.Head()
- require.NoError(t, err)
- initialCommit := headRef.Hash()
-
- // Create the gitignored file and a legitimate new file on disk
- if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("SECRET=abc123\n"), 0o644); err != nil {
- t.Fatalf("failed to write .env: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tempDir, "config.go"), []byte("package config\n"), 0o644); err != nil {
- t.Fatalf("failed to write config.go: %v", err)
- }
-
- t.Chdir(tempDir)
-
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- // First checkpoint
- firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("first WriteTemporary() error = %v", err)
- }
- require.False(t, firstResult.Skipped)
-
- // Subsequent checkpoint with .env reported as a new file
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- NewFiles: []string{"config.go", ".env"}, // Agent created both
- DeletedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "Second checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: false,
- })
- if err != nil {
- t.Fatalf("second WriteTemporary() error = %v", err)
- }
-
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // config.go SHOULD be in the tree
- _, err = tree.File("config.go")
- if err != nil {
- t.Errorf("config.go should be in checkpoint tree: %v", err)
- }
-
- // .env MUST NOT be in the tree
- _, err = tree.File(".env")
- if err == nil {
- t.Error("SECURITY: gitignored file .env leaked into checkpoint tree via NewFiles")
- }
-}
-
-// TestWriteTemporary_SubsequentCheckpoint_ExcludesNestedGitIgnoredFiles verifies that
-// gitignore patterns with directory wildcards (e.g., node_modules/) work for
-// subsequent checkpoints, not just the first checkpoint.
-func TestWriteTemporary_SubsequentCheckpoint_ExcludesNestedGitIgnoredFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- testutil.InitRepo(t, tempDir)
- repo, err := git.PlainOpen(tempDir)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("node_modules/\n"), 0o644); err != nil {
- t.Fatalf("failed to write .gitignore: %v", err)
- }
- if _, err := worktree.Add(".gitignore"); err != nil {
- t.Fatalf("failed to add .gitignore: %v", err)
- }
-
- if err := os.WriteFile(filepath.Join(tempDir, "index.js"), []byte("console.log('hello')\n"), 0o644); err != nil {
- t.Fatalf("failed to write index.js: %v", err)
- }
- if _, err := worktree.Add("index.js"); err != nil {
- t.Fatalf("failed to add index.js: %v", err)
- }
- testutil.GitCommit(t, tempDir, "Initial commit")
- headRef, err := repo.Head()
- require.NoError(t, err)
- initialCommit := headRef.Hash()
-
- // Create node_modules file on disk
- if err := os.MkdirAll(filepath.Join(tempDir, "node_modules", "pkg"), 0o755); err != nil {
- t.Fatalf("failed to create node_modules: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tempDir, "node_modules", "pkg", "index.js"), []byte("module.exports = {}"), 0o644); err != nil {
- t.Fatalf("failed to write node_modules file: %v", err)
- }
-
- t.Chdir(tempDir)
-
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- // First checkpoint
- firstResult, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("first WriteTemporary() error = %v", err)
- }
- require.False(t, firstResult.Skipped)
-
- // Subsequent checkpoint with node_modules file reported as modified
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{"index.js", "node_modules/pkg/index.js"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "Second checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: false,
- })
- if err != nil {
- t.Fatalf("second WriteTemporary() error = %v", err)
- }
-
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // index.js SHOULD be in the tree
- _, err = tree.File("index.js")
- if err != nil {
- t.Errorf("index.js should be in checkpoint tree: %v", err)
- }
-
- // node_modules/pkg/index.js MUST NOT be in the tree
- _, err = tree.File("node_modules/pkg/index.js")
- if err == nil {
- t.Error("SECURITY: gitignored file node_modules/pkg/index.js leaked into checkpoint tree")
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_UserAndAgentChanges verifies that
-// the first checkpoint captures both user's pre-existing changes and agent changes.
-func TestWriteTemporary_FirstCheckpoint_UserAndAgentChanges(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit README.md and main.go
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Original\n"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- mainFile := filepath.Join(tempDir, "main.go")
- if err := os.WriteFile(mainFile, []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write main.go: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- if _, err := worktree.Add("main.go"); err != nil {
- t.Fatalf("failed to add main.go: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // User modifies README.md BEFORE agent starts
- userModifiedContent := "# Modified by User\n"
- if err := os.WriteFile(readmeFile, []byte(userModifiedContent), 0o644); err != nil {
- t.Fatalf("failed to modify README: %v", err)
- }
-
- // Agent modifies main.go
- agentModifiedContent := "package main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n"
- if err := os.WriteFile(mainFile, []byte(agentModifiedContent), 0o644); err != nil {
- t.Fatalf("failed to modify main.go: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint - agent reports main.go as modified (from transcript)
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{"main.go"}, // Only agent-modified file in list
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the checkpoint contains BOTH changes
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // Check README.md has user's modification
- readmeTreeFile, err := tree.File("README.md")
- if err != nil {
- t.Fatalf("README.md not found in tree: %v", err)
- }
- readmeContent, err := readmeTreeFile.Contents()
- if err != nil {
- t.Fatalf("failed to read README.md content: %v", err)
- }
- if readmeContent != userModifiedContent {
- t.Errorf("README.md should have user's modification\ngot:\n%s\nwant:\n%s", readmeContent, userModifiedContent)
- }
-
- // Check main.go has agent's modification
- mainTreeFile, err := tree.File("main.go")
- if err != nil {
- t.Fatalf("main.go not found in tree: %v", err)
- }
- mainContent, err := mainTreeFile.Contents()
- if err != nil {
- t.Fatalf("failed to read main.go content: %v", err)
- }
- if mainContent != agentModifiedContent {
- t.Errorf("main.go should have agent's modification\ngot:\n%s\nwant:\n%s", mainContent, agentModifiedContent)
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_CapturesUserDeletedFiles verifies that
-// the first checkpoint excludes files that the user deleted before the session started.
-func TestWriteTemporary_FirstCheckpoint_CapturesUserDeletedFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit two files
- keepFile := filepath.Join(tempDir, "keep.txt")
- if err := os.WriteFile(keepFile, []byte("keep this"), 0o644); err != nil {
- t.Fatalf("failed to write keep.txt: %v", err)
- }
- deleteFile := filepath.Join(tempDir, "delete-me.txt")
- if err := os.WriteFile(deleteFile, []byte("delete this"), 0o644); err != nil {
- t.Fatalf("failed to write delete-me.txt: %v", err)
- }
-
- if _, err := worktree.Add("keep.txt"); err != nil {
- t.Fatalf("failed to add keep.txt: %v", err)
- }
- if _, err := worktree.Add("delete-me.txt"); err != nil {
- t.Fatalf("failed to add delete-me.txt: %v", err)
- }
-
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // User deletes delete-me.txt BEFORE the session starts
- if err := os.Remove(deleteFile); err != nil {
- t.Fatalf("failed to delete file: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- DeletedFiles: []string{}, // No agent deletions
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the checkpoint tree
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // keep.txt should be in the tree (unchanged from HEAD)
- if _, err := tree.File("keep.txt"); err != nil {
- t.Errorf("keep.txt should be in checkpoint tree: %v", err)
- }
-
- // delete-me.txt should NOT be in the tree (user deleted it)
- _, err = tree.File("delete-me.txt")
- if err == nil {
- t.Error("delete-me.txt should NOT be in checkpoint tree (user deleted it before session)")
- } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
- t.Fatalf("expected delete-me.txt to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_CapturesRenamedFiles verifies that
-// the first checkpoint captures renamed files correctly.
-func TestWriteTemporary_FirstCheckpoint_CapturesRenamedFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit a file
- oldFile := filepath.Join(tempDir, "old-name.txt")
- if err := os.WriteFile(oldFile, []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write old-name.txt: %v", err)
- }
-
- if _, err := worktree.Add("old-name.txt"); err != nil {
- t.Fatalf("failed to add old-name.txt: %v", err)
- }
-
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // User renames the file using git mv BEFORE the session starts
- // Using git mv ensures git reports this as R (rename) status, not separate D+A
- cmd := exec.CommandContext(context.Background(), "git", "mv", "old-name.txt", "new-name.txt")
- cmd.Dir = tempDir
- if err := cmd.Run(); err != nil {
- t.Fatalf("failed to git mv: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the checkpoint tree
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // new-name.txt should be in the tree
- if _, err := tree.File("new-name.txt"); err != nil {
- t.Errorf("new-name.txt should be in checkpoint tree: %v", err)
- }
-
- // old-name.txt should NOT be in the tree (renamed away)
- _, err = tree.File("old-name.txt")
- if err == nil {
- t.Error("old-name.txt should NOT be in checkpoint tree (file was renamed)")
- } else if !errors.Is(err, object.ErrFileNotFound) && !errors.Is(err, object.ErrEntryNotFound) {
- t.Fatalf("expected old-name.txt to be absent (ErrFileNotFound/ErrEntryNotFound), got: %v", err)
- }
-}
-
-// TestWriteTemporary_FirstCheckpoint_FilenamesWithSpaces verifies that
-// filenames with spaces are handled correctly.
-func TestWriteTemporary_FirstCheckpoint_FilenamesWithSpaces(t *testing.T) {
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit a simple file first
- simpleFile := filepath.Join(tempDir, "simple.txt")
- if err := os.WriteFile(simpleFile, []byte("simple"), 0o644); err != nil {
- t.Fatalf("failed to write simple.txt: %v", err)
- }
-
- if _, err := worktree.Add("simple.txt"); err != nil {
- t.Fatalf("failed to add simple.txt: %v", err)
- }
-
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // User creates a file with spaces in the name
- spacesFile := filepath.Join(tempDir, "file with spaces.txt")
- if err := os.WriteFile(spacesFile, []byte("content with spaces"), 0o644); err != nil {
- t.Fatalf("failed to write file with spaces: %v", err)
- }
-
- // Change to temp dir so paths.WorktreeRoot() works correctly
- t.Chdir(tempDir)
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, ".trace", "metadata", "test-session")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create checkpoint store and write first checkpoint
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ModifiedFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: ".trace/metadata/test-session",
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() error = %v", err)
- }
-
- // Verify the checkpoint tree
- commit, err := repo.CommitObject(result.CommitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // "file with spaces.txt" should be in the tree with correct name
- if _, err := tree.File("file with spaces.txt"); err != nil {
- t.Errorf("'file with spaces.txt' should be in checkpoint tree: %v", err)
- }
-}
-
-// =============================================================================
-// Duplicate Session ID Tests - Tests for ENT-252 where the same session ID
-// written twice to the same checkpoint should update in-place, not append.
-// =============================================================================
-
-// TestWriteCommitted_DuplicateSessionIDUpdatesInPlace verifies that writing
-// the same session ID twice to the same checkpoint updates the existing slot
-// rather than creating a duplicate subdirectory.
-func TestWriteCommitted_DuplicateSessionIDUpdatesInPlace(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("deda01234567")
-
- // Write session "X" with initial data
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-X",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "session X v1"}`)),
- FilesTouched: []string{"a.go"},
- CheckpointsCount: 3,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- APICallCount: 5,
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session X v1 error = %v", err)
- }
-
- // Write session "Y"
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-Y",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "session Y"}`)),
- FilesTouched: []string{"b.go"},
- CheckpointsCount: 2,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 50,
- OutputTokens: 25,
- APICallCount: 3,
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session Y error = %v", err)
- }
-
- // Write session "X" again with updated data (should replace, not append)
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-X",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "session X v2"}`)),
- FilesTouched: []string{"a.go", "c.go"},
- CheckpointsCount: 5,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 200,
- OutputTokens: 100,
- APICallCount: 10,
- },
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session X v2 error = %v", err)
- }
-
- // Read the checkpoint summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
- require.NotNil(t, summary, "ReadCommitted() returned nil summary")
-
- // Should have 2 sessions, not 3
- if len(summary.Sessions) != 2 {
- t.Errorf("len(summary.Sessions) = %d, want 2 (not 3 - duplicate should be replaced)", len(summary.Sessions))
- }
-
- // Verify session 0 has updated data (session X v2)
- content0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if content0.Metadata.SessionID != "session-X" {
- t.Errorf("session 0 SessionID = %q, want %q", content0.Metadata.SessionID, "session-X")
- }
- if content0.Metadata.CheckpointsCount != 5 {
- t.Errorf("session 0 CheckpointsCount = %d, want 5", content0.Metadata.CheckpointsCount)
- }
- if !strings.Contains(string(content0.Transcript), "session X v2") {
- t.Errorf("session 0 transcript should contain 'session X v2', got %s", string(content0.Transcript))
- }
-
- // Verify session 1 is still "Y" (unchanged)
- content1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
- if err != nil {
- t.Fatalf("ReadSessionContent(1) error = %v", err)
- }
- if content1.Metadata.SessionID != "session-Y" {
- t.Errorf("session 1 SessionID = %q, want %q", content1.Metadata.SessionID, "session-Y")
- }
-
- // Verify aggregated stats: count = 5 (X v2) + 2 (Y) = 7
- if summary.CheckpointsCount != 7 {
- t.Errorf("summary.CheckpointsCount = %d, want 7", summary.CheckpointsCount)
- }
-
- // Verify merged files: [a.go, b.go, c.go]
- expectedFiles := []string{"a.go", "b.go", "c.go"}
- if len(summary.FilesTouched) != len(expectedFiles) {
- t.Errorf("len(summary.FilesTouched) = %d, want %d", len(summary.FilesTouched), len(expectedFiles))
- }
- for i, want := range expectedFiles {
- if i < len(summary.FilesTouched) && summary.FilesTouched[i] != want {
- t.Errorf("summary.FilesTouched[%d] = %q, want %q", i, summary.FilesTouched[i], want)
- }
- }
-
- // Verify aggregated tokens: 200 (X v2) + 50 (Y) = 250
- if summary.TokenUsage == nil {
- t.Fatal("summary.TokenUsage should not be nil")
- }
- if summary.TokenUsage.InputTokens != 250 {
- t.Errorf("summary.TokenUsage.InputTokens = %d, want 250", summary.TokenUsage.InputTokens)
- }
- if summary.TokenUsage.OutputTokens != 125 {
- t.Errorf("summary.TokenUsage.OutputTokens = %d, want 125", summary.TokenUsage.OutputTokens)
- }
- if summary.TokenUsage.APICallCount != 13 {
- t.Errorf("summary.TokenUsage.APICallCount = %d, want 13", summary.TokenUsage.APICallCount)
- }
-}
-
-// TestWriteCommitted_DuplicateSessionIDSingleSession verifies that writing
-// the same session ID twice when it's the only session updates in-place.
-func TestWriteCommitted_DuplicateSessionIDSingleSession(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("dedb07654321")
-
- // Write session "X" with initial data
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-X",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "v1"}`)),
- FilesTouched: []string{"old.go"},
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() v1 error = %v", err)
- }
-
- // Write session "X" again with updated data
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-X",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"message": "v2"}`)),
- FilesTouched: []string{"new.go"},
- CheckpointsCount: 5,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() v2 error = %v", err)
- }
-
- // Read the checkpoint summary
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
- require.NotNil(t, summary, "ReadCommitted() returned nil summary")
-
- // Should have 1 session, not 2
- if len(summary.Sessions) != 1 {
- t.Errorf("len(summary.Sessions) = %d, want 1 (duplicate should be replaced)", len(summary.Sessions))
- }
-
- // Verify session has updated data
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if content.Metadata.SessionID != "session-X" {
- t.Errorf("session 0 SessionID = %q, want %q", content.Metadata.SessionID, "session-X")
- }
- if content.Metadata.CheckpointsCount != 5 {
- t.Errorf("session 0 CheckpointsCount = %d, want 5 (updated value)", content.Metadata.CheckpointsCount)
- }
- if !strings.Contains(string(content.Transcript), "v2") {
- t.Errorf("session 0 transcript should contain 'v2', got %s", string(content.Transcript))
- }
-
- // Verify aggregated stats match the single session
- if summary.CheckpointsCount != 5 {
- t.Errorf("summary.CheckpointsCount = %d, want 5", summary.CheckpointsCount)
- }
- expectedFiles := []string{"new.go"}
- if len(summary.FilesTouched) != 1 || summary.FilesTouched[0] != "new.go" {
- t.Errorf("summary.FilesTouched = %v, want %v", summary.FilesTouched, expectedFiles)
- }
-}
-
-// TestWriteCommitted_DuplicateSessionIDReusesIndex verifies that when a session ID
-// already exists at index 0, writing it again reuses index 0 (not index 2).
-// The session file paths in the summary must point to /0/, not /2/.
-func TestWriteCommitted_DuplicateSessionIDReusesIndex(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("dedc0abcdef1")
-
- // Write session A at index 0
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-A",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"v": 1}`)),
- CheckpointsCount: 1,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session A error = %v", err)
- }
-
- // Write session B at index 1
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-B",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"v": 2}`)),
- CheckpointsCount: 1,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session B error = %v", err)
- }
-
- // Write session A again — should reuse index 0, not create index 2
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-A",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"v": 3}`)),
- CheckpointsCount: 2,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() session A v2 error = %v", err)
- }
-
- summary, err := store.ReadCommitted(context.Background(), checkpointID)
- if err != nil {
- t.Fatalf("ReadCommitted() error = %v", err)
- }
-
- // Must still be 2 sessions
- if len(summary.Sessions) != 2 {
- t.Fatalf("len(summary.Sessions) = %d, want 2", len(summary.Sessions))
- }
-
- // Session A's file paths must point to subdirectory /0/, not /2/
- if !strings.Contains(summary.Sessions[0].Transcript, "/0/") {
- t.Errorf("session A should be at index 0, got transcript path %s", summary.Sessions[0].Transcript)
- }
-
- // Session B stays at /1/
- if !strings.Contains(summary.Sessions[1].Transcript, "/1/") {
- t.Errorf("session B should be at index 1, got transcript path %s", summary.Sessions[1].Transcript)
- }
-
- // Verify index 0 has the updated content
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if content.Metadata.SessionID != "session-A" {
- t.Errorf("session 0 SessionID = %q, want %q", content.Metadata.SessionID, "session-A")
- }
- if !strings.Contains(string(content.Transcript), `"v": 3`) {
- t.Errorf("session 0 should have updated transcript, got %s", string(content.Transcript))
- }
-}
-
-// TestWriteCommitted_DuplicateSessionIDClearsStaleFiles verifies that when a session
-// is overwritten in-place, optional files from the previous write (prompts, context)
-// do not persist if the new write omits them, and sibling session data is untouched.
-func TestWriteCommitted_DuplicateSessionIDClearsStaleFiles(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("dedd0abcdef2")
-
- // Write session A with prompts and context
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-A",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"v": 1}`)),
- Prompts: []string{"original prompt"},
- CheckpointsCount: 1,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() A v1 error = %v", err)
- }
-
- // Write session B with prompts
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-B",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"session": "B"}`)),
- Prompts: []string{"B prompt"},
- CheckpointsCount: 1,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() B error = %v", err)
- }
-
- // Overwrite session A WITHOUT prompts
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "session-A",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"v": 2}`)),
- Prompts: nil,
- CheckpointsCount: 2,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() A v2 error = %v", err)
- }
-
- // Session A: stale prompts should be cleared
- contentA, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent(0) error = %v", err)
- }
- if contentA.Prompts != "" {
- t.Errorf("session A stale prompts should be cleared, got %q", contentA.Prompts)
- }
- if !strings.Contains(string(contentA.Transcript), `"v": 2`) {
- t.Errorf("session A transcript should be updated, got %s", string(contentA.Transcript))
- }
-
- // Session B: data must be untouched
- contentB, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
- if err != nil {
- t.Fatalf("ReadSessionContent(1) error = %v", err)
- }
- if contentB.Metadata.SessionID != "session-B" {
- t.Errorf("session B SessionID = %q, want %q", contentB.Metadata.SessionID, "session-B")
- }
- if !strings.Contains(contentB.Prompts, "B prompt") {
- t.Errorf("session B prompts should be preserved, got %q", contentB.Prompts)
- }
-}
-
-// highEntropySecret is a string with Shannon entropy > 4.5 that will trigger redaction.
-const highEntropySecret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"
-
-func TestWriteCommitted_PreservesRedactedTranscript(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeef1")
-
- // Callers redact before passing to WriteCommitted; the store persists as-is.
- rawTranscript := []byte(`{"role":"assistant","content":"Here is your key: ` + highEntropySecret + `"}` + "\n")
- redactedTranscript, err := redact.JSONLBytes(rawTranscript)
- if err != nil {
- t.Fatalf("redact.JSONLBytes() error = %v", err)
- }
-
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "redact-transcript-session",
- Strategy: "manual-commit",
- Transcript: redactedTranscript,
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- if strings.Contains(string(content.Transcript), highEntropySecret) {
- t.Error("transcript should not contain the secret after redaction")
- }
- if !strings.Contains(string(content.Transcript), "REDACTED") {
- t.Error("transcript should contain REDACTED placeholder")
- }
-}
-
-func TestWriteCommitted_RedactsPromptSecrets(t *testing.T) {
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeef2")
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "redact-prompt-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}`)),
- Prompts: []string{"Set API_KEY=" + highEntropySecret},
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- if strings.Contains(content.Prompts, highEntropySecret) {
- t.Error("prompts should not contain the secret after redaction")
- }
- if !strings.Contains(content.Prompts, "REDACTED") {
- t.Error("prompts should contain REDACTED placeholder")
- }
-}
-
-func TestCopyMetadataDir_RedactsSecrets(t *testing.T) {
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- metadataDir := filepath.Join(tempDir, "metadata")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- // Write a JSONL file with a secret
- jsonlFile := filepath.Join(metadataDir, "agent.jsonl")
- if err := os.WriteFile(jsonlFile, []byte(`{"content":"key=`+highEntropySecret+`"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write jsonl file: %v", err)
- }
-
- // Write a plain text file with a secret
- txtFile := filepath.Join(metadataDir, "notes.txt")
- if err := os.WriteFile(txtFile, []byte("secret: "+highEntropySecret), 0o644); err != nil {
- t.Fatalf("failed to write txt file: %v", err)
- }
-
- store := NewGitStore(repo)
- entries := make(map[string]object.TreeEntry)
-
- if err := store.copyMetadataDir(metadataDir, "cp/", entries); err != nil {
- t.Fatalf("copyMetadataDir() error = %v", err)
- }
-
- // Verify both files were added
- if _, ok := entries["cp/agent.jsonl"]; !ok {
- t.Fatal("agent.jsonl should be in entries")
- }
- if _, ok := entries["cp/notes.txt"]; !ok {
- t.Fatal("notes.txt should be in entries")
- }
-
- // Read back the blob content and verify redaction
- for path, entry := range entries {
- blob, bErr := repo.BlobObject(entry.Hash)
- if bErr != nil {
- t.Fatalf("failed to read blob for %s: %v", path, bErr)
- }
- reader, rErr := blob.Reader()
- if rErr != nil {
- t.Fatalf("failed to get reader for %s: %v", path, rErr)
- }
- buf := make([]byte, blob.Size)
- if _, rErr = reader.Read(buf); rErr != nil && rErr.Error() != "EOF" {
- t.Fatalf("failed to read blob content for %s: %v", path, rErr)
- }
- reader.Close()
-
- content := string(buf)
- if strings.Contains(content, highEntropySecret) {
- t.Errorf("%s should not contain the secret after redaction", path)
- }
- if !strings.Contains(content, "REDACTED") {
- t.Errorf("%s should contain REDACTED placeholder", path)
- }
- }
-}
-
-// TestWriteCommitted_CLIVersionField verifies that versioninfo.Version is written
-// to both the root CheckpointSummary and session-level CommittedMetadata.
-func TestWriteCommitted_CLIVersionField(t *testing.T) {
- t.Parallel()
-
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- store := NewGitStore(repo)
-
- checkpointID := id.MustCheckpointID("b1c2d3e4f5a6")
- sessionID := "test-session-version"
-
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: sessionID,
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte("test transcript")),
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Read the metadata branch
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get metadata branch reference: %v", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- checkpointTree, err := tree.Tree(checkpointID.Path())
- if err != nil {
- t.Fatalf("failed to find checkpoint tree at %s: %v", checkpointID.Path(), err)
- }
-
- // Verify root metadata.json (CheckpointSummary) has CLIVersion
- metadataFile, err := checkpointTree.File(paths.MetadataFileName)
- if err != nil {
- t.Fatalf("failed to find root metadata.json: %v", err)
- }
-
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read root metadata.json: %v", err)
- }
-
- var summary CheckpointSummary
- if err := json.Unmarshal([]byte(content), &summary); err != nil {
- t.Fatalf("failed to parse root metadata.json: %v", err)
- }
-
- if summary.CLIVersion != versioninfo.Version {
- t.Errorf("CheckpointSummary.CLIVersion = %q, want %q", summary.CLIVersion, versioninfo.Version)
- }
-
- // Verify session-level metadata.json (CommittedMetadata) has CLIVersion
- sessionTree, err := checkpointTree.Tree("0")
- if err != nil {
- t.Fatalf("failed to get session tree: %v", err)
- }
-
- sessionMetadataFile, err := sessionTree.File(paths.MetadataFileName)
- if err != nil {
- t.Fatalf("failed to find session metadata.json: %v", err)
- }
-
- sessionContent, err := sessionMetadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read session metadata.json: %v", err)
- }
-
- var sessionMetadata CommittedMetadata
- if err := json.Unmarshal([]byte(sessionContent), &sessionMetadata); err != nil {
- t.Fatalf("failed to parse session metadata.json: %v", err)
- }
-
- if sessionMetadata.CLIVersion != versioninfo.Version {
- t.Errorf("CommittedMetadata.CLIVersion = %q, want %q", sessionMetadata.CLIVersion, versioninfo.Version)
- }
-}
-
-func TestWriteCommitted_ModelFieldAlwaysPresent(t *testing.T) {
- t.Parallel()
-
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- store := NewGitStore(repo)
-
- checkpointID := id.MustCheckpointID("c1d2e3f4a5b6")
- err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "test-session-model",
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte("test transcript")),
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get metadata branch reference: %v", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- sessionMetadataFile, err := tree.File(sessionMetadataPath)
- if err != nil {
- t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
- }
-
- sessionContent, err := sessionMetadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read session metadata.json: %v", err)
- }
-
- var sessionMetadata CommittedMetadata
- if err := json.Unmarshal([]byte(sessionContent), &sessionMetadata); err != nil {
- t.Fatalf("failed to parse session metadata.json: %v", err)
- }
-
- if sessionMetadata.Model != "" {
- t.Errorf("CommittedMetadata.Model = %q, want empty string", sessionMetadata.Model)
- }
- if !strings.Contains(sessionContent, `"model": ""`) {
- t.Errorf("session metadata.json should contain an explicit empty model field, got:\n%s", sessionContent)
- }
-}
-
-func TestRedactSummary_Nil(t *testing.T) {
- t.Parallel()
- result := redactSummary(nil)
- if result != nil {
- t.Error("redactSummary(nil) should return nil")
- }
-}
-
-func TestRedactSummary_WithSecrets(t *testing.T) {
- t.Parallel()
- summary := &Summary{
- Intent: "Set API_KEY=" + highEntropySecret,
- Outcome: "Configured key " + highEntropySecret + " successfully",
- Friction: []string{
- "Had to find " + highEntropySecret + " in env",
- "No issues here",
- },
- OpenItems: []string{
- "Rotate " + highEntropySecret,
- },
- Learnings: LearningsSummary{
- Repo: []string{
- "Found secret " + highEntropySecret + " in config",
- },
- Workflow: []string{
- "Use vault for " + highEntropySecret,
- },
- Code: []CodeLearning{
- {
- Path: "config/secrets.go",
- Line: 42,
- EndLine: 50,
- Finding: "Key " + highEntropySecret + " is hardcoded",
- },
- },
- },
- }
-
- result := redactSummary(summary)
-
- // Verify secrets are removed from all text fields
- if strings.Contains(result.Intent, highEntropySecret) {
- t.Error("Intent should not contain the secret")
- }
- if !strings.Contains(result.Intent, "REDACTED") {
- t.Error("Intent should contain REDACTED placeholder")
- }
-
- if strings.Contains(result.Outcome, highEntropySecret) {
- t.Error("Outcome should not contain the secret")
- }
-
- if strings.Contains(result.Friction[0], highEntropySecret) {
- t.Error("Friction[0] should not contain the secret")
- }
- if result.Friction[1] != "No issues here" {
- t.Errorf("Friction[1] should be unchanged, got %q", result.Friction[1])
- }
-
- if strings.Contains(result.OpenItems[0], highEntropySecret) {
- t.Error("OpenItems[0] should not contain the secret")
- }
-
- if strings.Contains(result.Learnings.Repo[0], highEntropySecret) {
- t.Error("Learnings.Repo[0] should not contain the secret")
- }
-
- if strings.Contains(result.Learnings.Workflow[0], highEntropySecret) {
- t.Error("Learnings.Workflow[0] should not contain the secret")
- }
-
- // Verify CodeLearning structural fields preserved, Finding redacted
- cl := result.Learnings.Code[0]
- if cl.Path != "config/secrets.go" {
- t.Errorf("CodeLearning.Path should be preserved, got %q", cl.Path)
- }
- if cl.Line != 42 {
- t.Errorf("CodeLearning.Line should be preserved, got %d", cl.Line)
- }
- if cl.EndLine != 50 {
- t.Errorf("CodeLearning.EndLine should be preserved, got %d", cl.EndLine)
- }
- if strings.Contains(cl.Finding, highEntropySecret) {
- t.Error("CodeLearning.Finding should not contain the secret")
- }
- if !strings.Contains(cl.Finding, "REDACTED") {
- t.Error("CodeLearning.Finding should contain REDACTED placeholder")
- }
-
- // Verify original is not mutated
- if !strings.Contains(summary.Intent, highEntropySecret) {
- t.Error("original Summary.Intent should not be mutated")
- }
-}
-
-func TestRedactSummary_NoSecrets(t *testing.T) {
- t.Parallel()
- summary := &Summary{
- Intent: "Fix a bug",
- Outcome: "Bug fixed",
- Friction: []string{"None"},
- OpenItems: []string{},
- Learnings: LearningsSummary{
- Repo: []string{"Found the pattern"},
- Workflow: []string{"Use TDD"},
- Code: []CodeLearning{
- {Path: "main.go", Line: 1, Finding: "Good code"},
- },
- },
- }
-
- result := redactSummary(summary)
-
- if result.Intent != "Fix a bug" {
- t.Errorf("Intent should be unchanged, got %q", result.Intent)
- }
- if result.Outcome != "Bug fixed" {
- t.Errorf("Outcome should be unchanged, got %q", result.Outcome)
- }
- if result.Learnings.Code[0].Finding != "Good code" {
- t.Errorf("Finding should be unchanged, got %q", result.Learnings.Code[0].Finding)
- }
-}
-
-func TestRedactStringSlice_NilAndEmpty(t *testing.T) {
- t.Parallel()
-
- // nil input should return nil (not empty slice)
- if result := redactStringSlice(nil); result != nil {
- t.Errorf("redactStringSlice(nil) should return nil, got %v", result)
- }
-
- // empty slice should return empty slice (not nil)
- result := redactStringSlice([]string{})
- if result == nil {
- t.Error("redactStringSlice([]string{}) should return empty slice, not nil")
- }
- if len(result) != 0 {
- t.Errorf("redactStringSlice([]string{}) should return empty slice, got len %d", len(result))
- }
-}
-
-func TestRedactCodeLearnings_NilAndEmpty(t *testing.T) {
- t.Parallel()
-
- // nil input should return nil
- if result := redactCodeLearnings(nil); result != nil {
- t.Errorf("redactCodeLearnings(nil) should return nil, got %v", result)
- }
-
- // empty slice should return empty slice
- result := redactCodeLearnings([]CodeLearning{})
- if result == nil {
- t.Error("redactCodeLearnings([]CodeLearning{}) should return empty slice, not nil")
- }
- if len(result) != 0 {
- t.Errorf("expected len 0, got %d", len(result))
- }
-}
-
-func TestWriteCommitted_RedactsSummarySecrets(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeef7")
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "redact-summary-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- Summary: &Summary{
- Intent: "Used key " + highEntropySecret + " to auth",
- Outcome: "Authenticated with " + highEntropySecret,
- },
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- if content.Metadata.Summary == nil {
- t.Fatal("Summary should not be nil")
- }
- if strings.Contains(content.Metadata.Summary.Intent, highEntropySecret) {
- t.Error("Summary.Intent should not contain the secret after redaction")
- }
- if !strings.Contains(content.Metadata.Summary.Intent, "REDACTED") {
- t.Error("Summary.Intent should contain REDACTED placeholder")
- }
- if strings.Contains(content.Metadata.Summary.Outcome, highEntropySecret) {
- t.Error("Summary.Outcome should not contain the secret after redaction")
- }
-}
-
-func TestUpdateSummary_RedactsSecrets(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeef8")
-
- // First write a checkpoint without a summary
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "update-summary-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Now update the summary with a secret
- err = store.UpdateSummary(context.Background(), checkpointID, &Summary{
- Intent: "Rotated key " + highEntropySecret,
- Outcome: "Done",
- })
- if err != nil {
- t.Fatalf("UpdateSummary() error = %v", err)
- }
-
- content, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
- if err != nil {
- t.Fatalf("ReadSessionContent() error = %v", err)
- }
-
- if content.Metadata.Summary == nil {
- t.Fatal("Summary should not be nil after update")
- }
- if strings.Contains(content.Metadata.Summary.Intent, highEntropySecret) {
- t.Error("Updated Summary.Intent should not contain the secret")
- }
- if !strings.Contains(content.Metadata.Summary.Intent, "REDACTED") {
- t.Error("Updated Summary.Intent should contain REDACTED placeholder")
- }
-}
-
-func TestWriteCommitted_SubagentTranscript_JSONLFallback(t *testing.T) {
- t.Parallel()
- repo, _ := setupBranchTestRepo(t)
- store := NewGitStore(repo)
- checkpointID := id.MustCheckpointID("aabbccddeef9")
-
- // Create a temp file with invalid JSONL containing a secret
- tmpDir := t.TempDir()
- transcriptPath := filepath.Join(tmpDir, "agent.jsonl")
- invalidJSONL := "this is not valid JSON but has a secret " + highEntropySecret + " in it"
- if err := os.WriteFile(transcriptPath, []byte(invalidJSONL), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
- CheckpointID: checkpointID,
- SessionID: "jsonl-fallback-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"msg":"safe"}` + "\n")),
- CheckpointsCount: 1,
- AuthorName: "Test Author",
- AuthorEmail: "test@example.com",
- IsTask: true,
- ToolUseID: "toolu_test123",
- AgentID: "agent1",
- SubagentTranscriptPath: transcriptPath,
- })
- if err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Read back the subagent transcript from the tree
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get branch ref: %v", err)
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- agentPath := checkpointID.Path() + "/tasks/toolu_test123/agent-agent1.jsonl"
- file, err := tree.File(agentPath)
- if err != nil {
- t.Fatalf("subagent transcript should exist at %s (JSONL fallback should not drop it): %v", agentPath, err)
- }
-
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("failed to read subagent transcript: %v", err)
- }
-
- // Verify the transcript was stored (not dropped) and secret was redacted
- if content == "" {
- t.Error("subagent transcript should not be empty")
- }
- if strings.Contains(content, highEntropySecret) {
- t.Error("subagent transcript should not contain the secret after fallback redaction")
- }
- if !strings.Contains(content, "REDACTED") {
- t.Error("subagent transcript should contain REDACTED from fallback redaction")
- }
-}
-
-func TestWriteTemporaryTask_SubagentTranscript_RedactsSecrets(t *testing.T) {
- // Cannot use t.Parallel() because t.Chdir is required for paths.WorktreeRoot()
- tempDir := t.TempDir()
-
- // Initialize a git repository with an initial commit
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- readmeFile := filepath.Join(tempDir, "README.md")
- if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
- t.Fatalf("failed to write README: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to add README: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(tempDir)
-
- // Create a temp file with invalid JSONL containing a secret
- transcriptPath := filepath.Join(tempDir, "agent-transcript.jsonl")
- invalidJSONL := "this is not valid JSON but has a secret " + highEntropySecret + " in it"
- if err := os.WriteFile(transcriptPath, []byte(invalidJSONL), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- _, err = store.WriteTemporaryTask(context.Background(), WriteTemporaryTaskOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ToolUseID: "toolu_test456",
- AgentID: "agent1",
- SubagentTranscriptPath: transcriptPath,
- CheckpointUUID: "test-uuid",
- CommitMessage: "Task checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("WriteTemporaryTask() error = %v", err)
- }
-
- // Find the shadow branch and read the subagent transcript
- shadowBranch := ShadowBranchNameForCommit(baseCommit, "")
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- if err != nil {
- t.Fatalf("failed to get shadow branch ref: %v", err)
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- agentPath := paths.TraceMetadataDir + "/test-session/tasks/toolu_test456/agent-agent1.jsonl"
- file, err := tree.File(agentPath)
- if err != nil {
- t.Fatalf("subagent transcript should exist at %s: %v", agentPath, err)
- }
-
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("failed to read subagent transcript: %v", err)
- }
-
- // Verify the transcript was stored (not dropped) and secret was redacted
- if content == "" {
- t.Error("subagent transcript should not be empty")
- }
- if strings.Contains(content, highEntropySecret) {
- t.Error("subagent transcript on shadow branch should not contain the secret after redaction")
- }
- if !strings.Contains(content, "REDACTED") {
- t.Error("subagent transcript on shadow branch should contain REDACTED")
- }
-}
-
-func TestAddDirectoryToEntries_PathTraversal(t *testing.T) {
- t.Parallel()
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create a directory structure where the relative path could escape
- metadataDir := filepath.Join(tempDir, "metadata")
- subDir := filepath.Join(metadataDir, "sub")
- if err := os.MkdirAll(subDir, 0o755); err != nil {
- t.Fatalf("failed to create dirs: %v", err)
- }
-
- // Create a regular file — should be included
- regularFile := filepath.Join(subDir, "data.txt")
- if err := os.WriteFile(regularFile, []byte("safe content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
-
- entries := make(map[string]object.TreeEntry)
- err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, ".trace/metadata/session", entries)
- if err != nil {
- t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
- }
-
- // Verify the regular file was included with correct path
- expectedPath := filepath.ToSlash(filepath.Join(".trace/metadata/session", "sub", "data.txt"))
- if _, ok := entries[expectedPath]; !ok {
- t.Errorf("expected entry at %q, got entries: %v", expectedPath, entries)
- }
-}
-
-func TestAddDirectoryToEntries_SkipsSymlinks(t *testing.T) {
- t.Parallel()
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create metadata directory
- metadataDir := filepath.Join(tempDir, "metadata")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- // Create a regular file
- regularFile := filepath.Join(metadataDir, "regular.txt")
- if err := os.WriteFile(regularFile, []byte("regular content"), 0o644); err != nil {
- t.Fatalf("failed to create regular file: %v", err)
- }
-
- // Create a sensitive file outside the metadata directory
- sensitiveFile := filepath.Join(tempDir, "sensitive.txt")
- if err := os.WriteFile(sensitiveFile, []byte("SECRET DATA"), 0o644); err != nil {
- t.Fatalf("failed to create sensitive file: %v", err)
- }
-
- // Create a symlink inside metadata directory pointing to the sensitive file
- symlinkPath := filepath.Join(metadataDir, "sneaky-link")
- if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
- t.Fatalf("failed to create symlink: %v", err)
- }
-
- entries := make(map[string]object.TreeEntry)
- err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, "checkpoint/", entries)
- if err != nil {
- t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
- }
-
- // Verify regular file was included
- if _, ok := entries["checkpoint/regular.txt"]; !ok {
- t.Error("regular.txt should be included in entries")
- }
-
- // Verify symlink was NOT included
- if _, ok := entries["checkpoint/sneaky-link"]; ok {
- t.Error("symlink should NOT be included in entries — this would allow reading files outside the metadata directory")
- }
-
- if len(entries) != 1 {
- t.Errorf("expected 1 entry, got %d", len(entries))
- }
-}
-
-func TestAddDirectoryToEntries_SkipsSymlinkedDirectories(t *testing.T) {
- t.Parallel()
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create metadata directory with a regular file
- metadataDir := filepath.Join(tempDir, "metadata")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- regularFile := filepath.Join(metadataDir, "regular.txt")
- if err := os.WriteFile(regularFile, []byte("regular content"), 0o644); err != nil {
- t.Fatalf("failed to create regular file: %v", err)
- }
-
- // Create an external directory with sensitive files
- externalDir := filepath.Join(tempDir, "external-secrets")
- if err := os.MkdirAll(externalDir, 0o755); err != nil {
- t.Fatalf("failed to create external dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(externalDir, "secret.txt"), []byte("SECRET DATA"), 0o644); err != nil {
- t.Fatalf("failed to create secret file: %v", err)
- }
-
- // Create a symlink to the external directory inside metadata
- symlinkDir := filepath.Join(metadataDir, "evil-dir-link")
- if err := os.Symlink(externalDir, symlinkDir); err != nil {
- t.Fatalf("failed to create directory symlink: %v", err)
- }
-
- entries := make(map[string]object.TreeEntry)
- err = addDirectoryToEntriesWithAbsPath(repo, metadataDir, "checkpoint/", entries)
- if err != nil {
- t.Fatalf("addDirectoryToEntriesWithAbsPath failed: %v", err)
- }
-
- // Verify regular file was included
- if _, ok := entries["checkpoint/regular.txt"]; !ok {
- t.Error("regular.txt should be included in entries")
- }
-
- // Verify files from the symlinked directory were NOT included
- if _, ok := entries["checkpoint/evil-dir-link/secret.txt"]; ok {
- t.Error("files inside symlinked directory should NOT be included — this would allow reading files outside the metadata directory")
- }
-
- if len(entries) != 1 {
- t.Errorf("expected 1 entry (regular.txt only), got %d: %v", len(entries), entries)
- }
-}
-
-// TestWriteTemporaryTask_ExcludesGitIgnoredFiles verifies that task (subagent)
-// checkpoints also filter out gitignored files. This is the same vulnerability as
-// the WriteTemporary path — a subagent that touches .env must not leak it into the
-// shadow branch.
-func TestWriteTemporaryTask_ExcludesGitIgnoredFiles(t *testing.T) {
- tempDir := t.TempDir()
-
- repo, err := git.PlainInit(tempDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create .gitignore that ignores .env
- if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(".env\n"), 0o644); err != nil {
- t.Fatalf("failed to write .gitignore: %v", err)
- }
- if _, err := worktree.Add(".gitignore"); err != nil {
- t.Fatalf("failed to add .gitignore: %v", err)
- }
-
- if err := os.WriteFile(filepath.Join(tempDir, "main.go"), []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write main.go: %v", err)
- }
- if _, err := worktree.Add("main.go"); err != nil {
- t.Fatalf("failed to add main.go: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // Create gitignored .env file and a legitimate file on disk
- if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("API_KEY=sk-secret-1234\n"), 0o644); err != nil {
- t.Fatalf("failed to write .env: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tempDir, "handler.go"), []byte("package main\n\nfunc handler() {}\n"), 0o644); err != nil {
- t.Fatalf("failed to write handler.go: %v", err)
- }
-
- t.Chdir(tempDir)
-
- // Create subagent transcript file
- transcriptPath := filepath.Join(tempDir, "agent-transcript.jsonl")
- if err := os.WriteFile(transcriptPath, []byte(`{"role":"assistant","content":"done"}`+"\n"), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- store := NewGitStore(repo)
- baseCommit := initialCommit.String()
-
- // Write task checkpoint where subagent reports .env as modified
- commitHash, err := store.WriteTemporaryTask(context.Background(), WriteTemporaryTaskOptions{
- SessionID: "test-session",
- BaseCommit: baseCommit,
- ToolUseID: "toolu_test789",
- AgentID: "agent1",
- ModifiedFiles: []string{"handler.go", ".env"}, // Subagent reports both
- NewFiles: []string{},
- DeletedFiles: []string{},
- SubagentTranscriptPath: transcriptPath,
- CheckpointUUID: "test-uuid",
- CommitMessage: "Task checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("WriteTemporaryTask() error = %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // handler.go SHOULD be in the tree
- _, err = tree.File("handler.go")
- if err != nil {
- t.Errorf("handler.go should be in task checkpoint tree: %v", err)
- }
-
- // .env MUST NOT be in the tree
- _, err = tree.File(".env")
- if err == nil {
- t.Error("SECURITY: gitignored file .env leaked into task checkpoint tree — secrets exposed on shadow branch via subagent")
- }
-}
diff --git a/cli/checkpoint/committed.go b/cli/checkpoint/committed.go
index 36f4751..c24997a 100644
--- a/cli/checkpoint/committed.go
+++ b/cli/checkpoint/committed.go
@@ -1,42 +1,33 @@
package checkpoint
import (
- "bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
- "io"
"log/slog"
"os"
"path/filepath"
"sort"
- "strconv"
"strings"
"time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/codex"
- "github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/jsonutil"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/settings"
- "github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/cli/validation"
- "github.com/GrayCodeAI/trace/cli/vercelconfig"
"github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/GrayCodeAI/trace/perf"
"github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
- "github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
- "github.com/go-git/go-git/v6/utils/binary"
)
// errStopIteration is used to stop commit iteration early in GetCheckpointAuthor.
@@ -834,1229 +825,3 @@ func redactStringSlice(ss []string) []string {
}
return out
}
-
-// redactCodeLearnings redacts only the Finding field, preserving Path/Line/EndLine.
-func redactCodeLearnings(cls []CodeLearning) []CodeLearning {
- if cls == nil {
- return nil
- }
- out := make([]CodeLearning, len(cls))
- for i, cl := range cls {
- out[i] = CodeLearning{
- Path: cl.Path,
- Line: cl.Line,
- EndLine: cl.EndLine,
- Finding: redact.String(cl.Finding),
- }
- }
- return out
-}
-
-// readMetadataFromBlob reads CommittedMetadata from a blob hash.
-func (s *GitStore) readMetadataFromBlob(hash plumbing.Hash) (*CommittedMetadata, error) {
- return readJSONFromBlob[CommittedMetadata](s.repo, hash)
-}
-
-// buildCommitMessage constructs the commit message with proper trailers.
-// The commit subject is always "Checkpoint: " for consistency.
-// If CommitSubject is provided (e.g., for task checkpoints), it's included in the body.
-func (s *GitStore) buildCommitMessage(opts WriteCommittedOptions, taskMetadataPath string) string {
- var commitMsg strings.Builder
-
- // Subject line is always the checkpoint ID for consistent formatting
- fmt.Fprintf(&commitMsg, "Checkpoint: %s\n\n", opts.CheckpointID)
-
- // Include custom description in body if provided (e.g., task checkpoint details)
- if opts.CommitSubject != "" {
- commitMsg.WriteString(opts.CommitSubject + "\n\n")
- }
- fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.SessionTrailerKey, opts.SessionID)
- fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.StrategyTrailerKey, opts.Strategy)
- if opts.Agent != "" {
- fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.AgentTrailerKey, opts.Agent)
- }
- if opts.EphemeralBranch != "" {
- fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.EphemeralBranchTrailerKey, opts.EphemeralBranch)
- }
- if taskMetadataPath != "" {
- fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.MetadataTaskTrailerKey, taskMetadataPath)
- }
-
- return commitMsg.String()
-}
-
-// incrementalCheckpointData represents an incremental checkpoint during subagent execution.
-// This mirrors strategy.SubagentCheckpoint but avoids import cycles.
-type incrementalCheckpointData struct {
- Type string `json:"type"`
- ToolUseID string `json:"tool_use_id"`
- Timestamp time.Time `json:"timestamp"`
- Data json.RawMessage `json:"data"`
-}
-
-// taskCheckpointData represents a final task checkpoint.
-// This mirrors strategy.TaskCheckpoint but avoids import cycles.
-type taskCheckpointData struct {
- SessionID string `json:"session_id"`
- ToolUseID string `json:"tool_use_id"`
- CheckpointUUID string `json:"checkpoint_uuid"`
- AgentID string `json:"agent_id,omitempty"`
-}
-
-// ReadCommitted reads a committed checkpoint's summary by ID from the trace/checkpoints/v1 branch.
-// Returns only the CheckpointSummary (paths + aggregated stats), not actual content.
-// Use ReadSessionContent to read actual transcript/prompts/context.
-// Returns nil, nil if the checkpoint doesn't exist.
-//
-// The storage format uses numbered subdirectories for each session (0-based):
-//
-// /
-// ├── metadata.json # CheckpointSummary with sessions map
-// ├── 0/ # First session
-// │ ├── metadata.json # Session-specific metadata
-// │ └── full.jsonl # Transcript
-// ├── 1/ # Second session
-// └── ...
-func (s *GitStore) ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- return s.readCommitted(ctx, checkpointID)
-}
-
-// readCommitted is the unlocked internal implementation. Callers must hold storerMu.
-func (s *GitStore) readCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
- if err := ctx.Err(); err != nil {
- return nil, err //nolint:wrapcheck // Propagating context cancellation
- }
-
- ft, err := s.getFetchingTree(ctx)
- if err != nil {
- return nil, nil //nolint:nilnil,nilerr // No sessions branch means no checkpoint exists
- }
-
- checkpointPath := checkpointID.Path()
- checkpointTree, err := ft.Tree(checkpointPath)
- if err != nil {
- return nil, nil //nolint:nilnil,nilerr // Checkpoint directory not found
- }
-
- // Read root metadata.json as CheckpointSummary (auto-fetches blob if needed)
- metadataFile, err := checkpointTree.File(paths.MetadataFileName)
- if err != nil {
- return nil, nil //nolint:nilnil,nilerr // metadata.json not found
- }
-
- content, err := metadataFile.Contents()
- if err != nil {
- return nil, fmt.Errorf("failed to read metadata.json: %w", err)
- }
-
- var summary CheckpointSummary
- if err := json.Unmarshal([]byte(content), &summary); err != nil {
- return nil, fmt.Errorf("failed to parse metadata.json: %w", err)
- }
-
- return &summary, nil
-}
-
-// ReadSessionMetadata reads only the metadata.json for a specific session within a checkpoint.
-// This is a lightweight read that avoids fetching transcript/prompt blobs.
-// sessionIndex is 0-based.
-func (s *GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- if err := ctx.Err(); err != nil {
- return nil, err //nolint:wrapcheck // Propagating context cancellation
- }
-
- ft, err := s.getFetchingTree(ctx)
- if err != nil {
- return nil, ErrCheckpointNotFound
- }
-
- checkpointPath := checkpointID.Path()
- sessionPath := fmt.Sprintf("%s/%d", checkpointPath, sessionIndex)
- sessionTree, err := ft.Tree(sessionPath)
- if err != nil {
- return nil, fmt.Errorf("%w: session %d not found: %w", ErrCheckpointNotFound, sessionIndex, err)
- }
-
- metadataFile, err := sessionTree.File(paths.MetadataFileName)
- if err != nil {
- return nil, fmt.Errorf("metadata.json not found for session %d: %w", sessionIndex, err)
- }
-
- content, err := metadataFile.Contents()
- if err != nil {
- return nil, fmt.Errorf("failed to read session metadata: %w", err)
- }
-
- var metadata CommittedMetadata
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- return nil, fmt.Errorf("failed to parse session metadata: %w", err)
- }
-
- return &metadata, nil
-}
-
-// ReadSessionContent reads the actual content for a specific session within a checkpoint.
-// sessionIndex is 0-based (0 for first session, 1 for second, etc.).
-// Returns the session's metadata, transcript, prompts, and context.
-// Returns ErrCheckpointNotFound if the checkpoint or session doesn't exist.
-// Returns ErrNoTranscript if the session exists but has no transcript.
-func (s *GitStore) ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- return s.readSessionContent(ctx, checkpointID, sessionIndex)
-}
-
-// readSessionContent is the unlocked internal implementation. Callers must hold storerMu.
-func (s *GitStore) readSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
- if err := ctx.Err(); err != nil {
- return nil, err //nolint:wrapcheck // Propagating context cancellation
- }
-
- ft, err := s.getFetchingTree(ctx)
- if err != nil {
- return nil, ErrCheckpointNotFound
- }
-
- checkpointPath := checkpointID.Path()
- checkpointTree, err := ft.Tree(checkpointPath)
- if err != nil {
- return nil, ErrCheckpointNotFound
- }
-
- // Get the session subdirectory
- sessionDir := strconv.Itoa(sessionIndex)
- sessionTree, err := checkpointTree.Tree(sessionDir)
- if err != nil {
- return nil, fmt.Errorf("%w: session %d not found: %w", ErrCheckpointNotFound, sessionIndex, err)
- }
-
- result := &SessionContent{}
-
- // Read session-specific metadata (auto-fetches blob if needed)
- var agentType types.AgentType
- if metadataFile, fileErr := sessionTree.File(paths.MetadataFileName); fileErr == nil {
- if content, contentErr := metadataFile.Contents(); contentErr == nil {
- if jsonErr := json.Unmarshal([]byte(content), &result.Metadata); jsonErr == nil {
- agentType = result.Metadata.Agent
- }
- }
- }
-
- // Read transcript (auto-fetches blobs if needed)
- if transcript, transcriptErr := readTranscriptFromTree(ctx, sessionTree, agentType); transcriptErr == nil && transcript != nil {
- result.Transcript = transcript
- }
-
- // Read prompts (auto-fetches blob if needed)
- if file, fileErr := sessionTree.File(paths.PromptFileName); fileErr == nil {
- if content, contentErr := file.Contents(); contentErr == nil {
- result.Prompts = content
- }
- }
-
- if len(result.Transcript) == 0 {
- return nil, ErrNoTranscript
- }
-
- return result, nil
-}
-
-// ReadLatestSessionContent is a convenience method that reads the latest session's content.
-// This is equivalent to ReadSessionContent(ctx, checkpointID, len(summary.Sessions)-1).
-func (s *GitStore) ReadLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- return s.readLatestSessionContent(ctx, checkpointID)
-}
-
-// readLatestSessionContent is the unlocked internal implementation. Callers must hold storerMu.
-func (s *GitStore) readLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) {
- summary, err := s.readCommitted(ctx, checkpointID)
- if err != nil {
- return nil, err
- }
- if summary == nil {
- return nil, ErrCheckpointNotFound
- }
- if len(summary.Sessions) == 0 {
- return nil, fmt.Errorf("checkpoint has no sessions: %s", checkpointID)
- }
-
- latestIndex := len(summary.Sessions) - 1
- return s.readSessionContent(ctx, checkpointID, latestIndex)
-}
-
-// ReadSessionContentByID reads a session's content by its session ID.
-// This is useful when you have the session ID but don't know its index within the checkpoint.
-// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
-// Returns an error if no session with the given ID exists in the checkpoint.
-func (s *GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- summary, err := s.readCommitted(ctx, checkpointID)
- if err != nil {
- return nil, err
- }
- if summary == nil {
- return nil, ErrCheckpointNotFound
- }
-
- // Iterate through sessions to find the one with matching session ID
- for i := range len(summary.Sessions) {
- content, readErr := s.readSessionContent(ctx, checkpointID, i)
- if readErr != nil {
- continue
- }
- if content != nil && content.Metadata.SessionID == sessionID {
- return content, nil
- }
- }
-
- return nil, fmt.Errorf("session %q not found in checkpoint %s", sessionID, checkpointID)
-}
-
-// ListCommitted lists all committed checkpoints from the trace/checkpoints/v1 branch.
-// Scans sharded paths: // directories containing metadata.json.
-//
-
-func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- if err := ctx.Err(); err != nil {
- return nil, err //nolint:wrapcheck // Propagating context cancellation
- }
-
- tree, err := s.getSessionsBranchTree()
- if err != nil {
- return []CommittedInfo{}, nil //nolint:nilerr // No sessions branch means empty list
- }
-
- var checkpoints []CommittedInfo
-
- // Scan sharded structure: <2-char-prefix>//metadata.json
- _ = WalkCheckpointShards(s.repo, tree, func(checkpointID id.CheckpointID, cpTreeHash plumbing.Hash) error { //nolint:errcheck // callback never returns errors
- checkpointTree, cpTreeErr := s.repo.TreeObject(cpTreeHash)
- if cpTreeErr != nil {
- return nil //nolint:nilerr // skip unreadable entries, continue walking
- }
-
- info := CommittedInfo{
- CheckpointID: checkpointID,
- }
-
- // Get details from root metadata file (CheckpointSummary format)
- if metadataFile, fileErr := checkpointTree.File(paths.MetadataFileName); fileErr == nil {
- if content, contentErr := metadataFile.Contents(); contentErr == nil {
- var summary CheckpointSummary
- if err := json.Unmarshal([]byte(content), &summary); err == nil {
- info.CheckpointsCount = summary.CheckpointsCount
- info.FilesTouched = summary.FilesTouched
- info.SessionCount = len(summary.Sessions)
-
- // Read session metadata from latest session to get Agent, SessionID, CreatedAt
- if len(summary.Sessions) > 0 {
- latestIndex := len(summary.Sessions) - 1
- latestDir := strconv.Itoa(latestIndex)
- if sessionTree, treeErr := checkpointTree.Tree(latestDir); treeErr == nil {
- if sessionMetadataFile, smErr := sessionTree.File(paths.MetadataFileName); smErr == nil {
- if sessionContent, scErr := sessionMetadataFile.Contents(); scErr == nil {
- var sessionMetadata CommittedMetadata
- if json.Unmarshal([]byte(sessionContent), &sessionMetadata) == nil {
- info.Agent = sessionMetadata.Agent
- info.SessionID = sessionMetadata.SessionID
- info.CreatedAt = sessionMetadata.CreatedAt
- }
- }
- }
- }
- }
- }
- }
- }
-
- checkpoints = append(checkpoints, info)
- return nil
- })
-
- // Sort by time (most recent first)
- sort.Slice(checkpoints, func(i, j int) bool {
- return checkpoints[i].CreatedAt.After(checkpoints[j].CreatedAt)
- })
-
- return checkpoints, nil
-}
-
-// GetTranscript retrieves the transcript for a specific checkpoint ID.
-// Returns the latest session's transcript.
-func (s *GitStore) GetTranscript(ctx context.Context, checkpointID id.CheckpointID) ([]byte, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- content, err := s.readLatestSessionContent(ctx, checkpointID)
- if err != nil {
- return nil, err
- }
- if len(content.Transcript) == 0 {
- return nil, fmt.Errorf("no transcript found for checkpoint: %s", checkpointID)
- }
- return content.Transcript, nil
-}
-
-// GetSessionLog retrieves the session transcript and session ID for a checkpoint.
-// This is the primary method for looking up session logs by checkpoint ID.
-// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
-// Returns ErrNoTranscript if the checkpoint exists but has no transcript.
-func (s *GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- content, err := s.readLatestSessionContent(ctx, cpID)
- if err != nil {
- return nil, "", err
- }
- return content.Transcript, content.Metadata.SessionID, nil
-}
-
-// LookupSessionLog is a convenience function that opens the repository and retrieves
-// a session log by checkpoint ID. This is the primary entry point for callers that
-// don't already have a GitStore instance.
-// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
-// Returns ErrNoTranscript if the checkpoint exists but has no transcript.
-func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
- repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
- if err != nil {
- return nil, "", fmt.Errorf("failed to open git repository: %w", err)
- }
- store := NewGitStore(repo)
- return store.GetSessionLog(ctx, cpID)
-}
-
-// UpdateSummary updates the summary field in the latest session's metadata.
-// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
-func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *Summary) error {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- if err := ctx.Err(); err != nil {
- return err //nolint:wrapcheck // Propagating context cancellation
- }
-
- // Ensure sessions branch exists
- if err := s.ensureSessionsBranch(ctx); err != nil {
- return fmt.Errorf("failed to ensure sessions branch: %w", err)
- }
-
- // Get branch ref and root tree hash (O(1), no flatten)
- parentHash, rootTreeHash, err := s.getSessionsBranchRef()
- if err != nil {
- return err
- }
-
- // Flatten only the checkpoint subtree
- basePath := checkpointID.Path() + "/"
- checkpointPath := checkpointID.Path()
- entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath)
- if err != nil {
- return err
- }
-
- // Read root CheckpointSummary to find the latest session
- rootMetadataPath := basePath + paths.MetadataFileName
- entry, exists := entries[rootMetadataPath]
- if !exists {
- return ErrCheckpointNotFound
- }
-
- checkpointSummary, err := s.readSummaryFromBlob(entry.Hash)
- if err != nil {
- return fmt.Errorf("failed to read checkpoint summary: %w", err)
- }
-
- // Find the latest session's metadata path (0-based indexing)
- latestIndex := len(checkpointSummary.Sessions) - 1
- sessionMetadataPath := fmt.Sprintf("%s%d/%s", basePath, latestIndex, paths.MetadataFileName)
- sessionEntry, exists := entries[sessionMetadataPath]
- if !exists {
- return fmt.Errorf("session metadata not found at %s", sessionMetadataPath)
- }
-
- // Read and update session metadata
- existingMetadata, err := s.readMetadataFromBlob(sessionEntry.Hash)
- if err != nil {
- return fmt.Errorf("failed to read session metadata: %w", err)
- }
-
- // Update the summary
- existingMetadata.Summary = redactSummary(summary)
-
- // Write updated session metadata
- metadataJSON, err := jsonutil.MarshalIndentWithNewline(existingMetadata, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal metadata: %w", err)
- }
- metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON)
- if err != nil {
- return fmt.Errorf("failed to create metadata blob: %w", err)
- }
- entries[sessionMetadataPath] = object.TreeEntry{
- Name: sessionMetadataPath,
- Mode: filemode.Regular,
- Hash: metadataHash,
- }
-
- // Build checkpoint subtree and splice into root (O(depth) tree surgery)
- newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries)
- if err != nil {
- return err
- }
-
- authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
- commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID)
- newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail)
- if err != nil {
- return err
- }
-
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- newRef := plumbing.NewHashReference(refName, newCommitHash)
- if err := s.repo.Storer.SetReference(newRef); err != nil {
- return fmt.Errorf("failed to set branch reference: %w", err)
- }
-
- return nil
-}
-
-// UpdateCommitted replaces the transcript, prompts, and context for an existing
-// committed checkpoint. Uses replace semantics: the full session transcript is
-// written, replacing whatever was stored at initial condensation time.
-//
-// This is called at stop time to finalize all checkpoints from the current turn
-// with the complete session transcript (from prompt to stop event).
-//
-// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
-func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- if opts.CheckpointID.IsEmpty() {
- return errors.New("invalid update options: checkpoint ID is required")
- }
-
- // Ensure sessions branch exists
- if err := s.ensureSessionsBranch(ctx); err != nil {
- return fmt.Errorf("failed to ensure sessions branch: %w", err)
- }
-
- // Get branch ref and root tree hash (O(1), no flatten)
- parentHash, rootTreeHash, err := s.getSessionsBranchRef()
- if err != nil {
- return err
- }
-
- // Flatten only the checkpoint subtree
- basePath := opts.CheckpointID.Path() + "/"
- checkpointPath := opts.CheckpointID.Path()
- entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath)
- if err != nil {
- return err
- }
-
- // Read root CheckpointSummary to find the session slot
- rootMetadataPath := basePath + paths.MetadataFileName
- entry, exists := entries[rootMetadataPath]
- if !exists {
- return ErrCheckpointNotFound
- }
-
- checkpointSummary, err := s.readSummaryFromBlob(entry.Hash)
- if err != nil {
- return fmt.Errorf("failed to read checkpoint summary: %w", err)
- }
- if len(checkpointSummary.Sessions) == 0 {
- return ErrCheckpointNotFound
- }
-
- // Find session index matching opts.SessionID
- sessionIndex := -1
- for i := range len(checkpointSummary.Sessions) {
- metaPath := fmt.Sprintf("%s%d/%s", basePath, i, paths.MetadataFileName)
- if metaEntry, metaExists := entries[metaPath]; metaExists {
- meta, metaErr := s.readMetadataFromBlob(metaEntry.Hash)
- if metaErr == nil && meta.SessionID == opts.SessionID {
- sessionIndex = i
- break
- }
- }
- }
- if sessionIndex == -1 {
- // Fall back to latest session; log so mismatches are diagnosable.
- sessionIndex = len(checkpointSummary.Sessions) - 1
- logging.Debug(
- ctx, "UpdateCommitted: session ID not found, falling back to latest",
- slog.String("session_id", opts.SessionID),
- slog.String("checkpoint_id", string(opts.CheckpointID)),
- slog.Int("fallback_index", sessionIndex),
- )
- }
-
- sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex)
-
- // Replace transcript (full replace, not append).
- // Transcript is pre-redacted by the caller (enforced by RedactedBytes type).
- if opts.Transcript.Len() > 0 {
- if err := s.replaceTranscript(ctx, opts.Transcript, opts.Agent, opts.PrecomputedBlobs, sessionPath, entries); err != nil {
- return fmt.Errorf("failed to replace transcript: %w", err)
- }
- }
-
- // Replace prompts (apply redaction as safety net)
- if len(opts.Prompts) > 0 {
- promptContent := redact.String(JoinPrompts(opts.Prompts))
- blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
- if err != nil {
- return fmt.Errorf("failed to create prompt blob: %w", err)
- }
- entries[sessionPath+paths.PromptFileName] = object.TreeEntry{
- Name: sessionPath + paths.PromptFileName,
- Mode: filemode.Regular,
- Hash: blobHash,
- }
- }
-
- // Build checkpoint subtree and splice into root (O(depth) tree surgery)
- newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries)
- if err != nil {
- return err
- }
- newTreeHash, err = s.maybeMergeVercelConfig(ctx, newTreeHash)
- if err != nil {
- return err
- }
-
- authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
- commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID)
- newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail)
- if err != nil {
- return err
- }
-
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- newRef := plumbing.NewHashReference(refName, newCommitHash)
- if err := s.repo.Storer.SetReference(newRef); err != nil {
- return fmt.Errorf("failed to set branch reference: %w", err)
- }
-
- return nil
-}
-
-// replaceTranscript writes the full transcript content, replacing any existing transcript.
-// Also removes any chunk files from a previous write and updates the content hash.
-//
-// Short-circuits when the existing content_hash.txt already matches the new
-// transcript's sha256 — in that case the chunk entries are preserved as-is and
-// no chunking/zlib happens. Use precomputed (non-nil) to reuse blob hashes
-// computed once across multiple checkpoints.
-func (s *GitStore) replaceTranscript(ctx context.Context, transcript redact.RedactedBytes, agentType types.AgentType, precomputed *PrecomputedTranscriptBlobs, sessionPath string, entries map[string]object.TreeEntry) error {
- // Ignore precompute if invariants are violated — fall back to fresh chunking.
- if precomputed != nil && !precomputed.isUsable() {
- precomputed = nil
- }
-
- // Compute the new content-hash string (cheap — SHA-256 over transcript bytes).
- var newContentHash string
- if precomputed != nil {
- newContentHash = precomputed.ContentHash
- } else {
- newContentHash = fmt.Sprintf("sha256:%x", sha256.Sum256(transcript.Bytes()))
- }
-
- // Short-circuit: if the existing content_hash.txt already matches, the
- // chunk entries currently in `entries` represent the same content. Leave
- // everything as-is and skip chunking + zlib.
- hashPath := sessionPath + paths.ContentHashFileName
- if existing, ok := entries[hashPath]; ok {
- if blob, err := s.repo.BlobObject(existing.Hash); err == nil {
- if rdr, rerr := blob.Reader(); rerr == nil {
- existingHash, readErr := io.ReadAll(rdr)
- _ = rdr.Close()
- if readErr == nil && string(existingHash) == newContentHash {
- return nil
- }
- }
- }
- }
-
- // Remove existing transcript files (base + any chunks)
- transcriptBase := sessionPath + paths.TranscriptFileName
- for key := range entries {
- if key == transcriptBase || strings.HasPrefix(key, transcriptBase+".") {
- delete(entries, key)
- }
- }
-
- // Resolve chunk hashes from precompute, or chunk + blob-write now.
- var chunkHashes []plumbing.Hash
- if precomputed != nil {
- chunkHashes = precomputed.ChunkHashes
- } else {
- chunks, err := chunkTranscript(ctx, transcript.Bytes(), agentType)
- if err != nil {
- return fmt.Errorf("failed to chunk transcript: %w", err)
- }
- chunkHashes = make([]plumbing.Hash, len(chunks))
- for i, chunk := range chunks {
- blobHash, err := CreateBlobFromContent(s.repo, chunk)
- if err != nil {
- return fmt.Errorf("failed to create transcript blob: %w", err)
- }
- chunkHashes[i] = blobHash
- }
- }
-
- // Record chunk files in the tree at v1 (full.jsonl) naming.
- for i, blobHash := range chunkHashes {
- chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i)
- entries[chunkPath] = object.TreeEntry{
- Name: chunkPath,
- Mode: filemode.Regular,
- Hash: blobHash,
- }
- }
-
- // Content-hash blob.
- var hashBlob plumbing.Hash
- if precomputed != nil {
- hashBlob = precomputed.ContentHashBlob
- } else {
- h, err := CreateBlobFromContent(s.repo, []byte(newContentHash))
- if err != nil {
- return fmt.Errorf("failed to create content hash blob: %w", err)
- }
- hashBlob = h
- }
- entries[hashPath] = object.TreeEntry{
- Name: hashPath,
- Mode: filemode.Regular,
- Hash: hashBlob,
- }
-
- return nil
-}
-
-// PrecomputeTranscriptBlobs chunks the given transcript and writes each chunk
-// plus the content-hash blob to the object store once, returning the resulting
-// hashes for reuse across multiple UpdateCommitted calls that share the same
-// transcript content.
-//
-// The returned blobs work for both v1 (full.jsonl) and v2 (raw_transcript)
-// paths since blob hashes are content-addressed (SHA-1 of chunk bytes). Only
-// the tree-entry filenames differ between v1 and v2.
-func PrecomputeTranscriptBlobs(ctx context.Context, repo *git.Repository, transcript redact.RedactedBytes, agentType types.AgentType) (*PrecomputedTranscriptBlobs, error) {
- raw := transcript.Bytes()
-
- chunks, err := chunkTranscript(ctx, raw, agentType)
- if err != nil {
- return nil, fmt.Errorf("failed to chunk transcript: %w", err)
- }
-
- chunkHashes := make([]plumbing.Hash, len(chunks))
- for i, chunk := range chunks {
- h, err := CreateBlobFromContent(repo, chunk)
- if err != nil {
- return nil, fmt.Errorf("failed to create transcript blob: %w", err)
- }
- chunkHashes[i] = h
- }
-
- contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(raw))
- hashBlob, err := CreateBlobFromContent(repo, []byte(contentHash))
- if err != nil {
- return nil, fmt.Errorf("failed to create content hash blob: %w", err)
- }
-
- return &PrecomputedTranscriptBlobs{
- ChunkHashes: chunkHashes,
- ContentHashBlob: hashBlob,
- ContentHash: contentHash,
- }, nil
-}
-
-// ensureSessionsBranch ensures the trace/checkpoints/v1 branch exists.
-func (s *GitStore) ensureSessionsBranch(ctx context.Context) error {
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- _, err := s.repo.Reference(refName, true)
- if err == nil {
- return nil // Branch exists
- }
-
- // Create orphan branch with empty tree
- emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry))
- if err != nil {
- return err
- }
- emptyTreeHash, err = s.maybeMergeVercelConfig(ctx, emptyTreeHash)
- if err != nil {
- return err
- }
-
- authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
- commitHash, err := s.createCommit(ctx, emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail)
- if err != nil {
- return err
- }
-
- newRef := plumbing.NewHashReference(refName, commitHash)
- if err := s.repo.Storer.SetReference(newRef); err != nil {
- return fmt.Errorf("failed to set branch reference: %w", err)
- }
- return nil
-}
-
-func (s *GitStore) maybeMergeVercelConfig(ctx context.Context, rootTreeHash plumbing.Hash) (plumbing.Hash, error) {
- if err := vercelconfig.InitSettings(ctx); err != nil {
- return plumbing.ZeroHash, fmt.Errorf("initialize vercel settings: %w", err)
- }
- mergedTreeHash, err := vercelconfig.MaybeMergeMetadataBranchConfig(s.repo, rootTreeHash)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("merge vercel metadata branch config: %w", err)
- }
- return mergedTreeHash, nil
-}
-
-// getFetchingTree returns a FetchingTree for the metadata branch.
-// If a blob fetcher is configured on the store, File() calls on the returned
-// tree will automatically fetch missing blobs from the remote.
-func (s *GitStore) getFetchingTree(ctx context.Context) (*FetchingTree, error) {
- tree, err := s.getSessionsBranchTree()
- if err != nil {
- return nil, err
- }
- return NewFetchingTree(ctx, tree, s.repo.Storer, s.blobFetcher), nil
-}
-
-// getSessionsBranchTree returns the tree object for the trace/checkpoints/v1 branch.
-// Falls back to origin/trace/checkpoints/v1 if the local branch doesn't exist.
-func (s *GitStore) getSessionsBranchTree() (*object.Tree, error) {
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := s.repo.Reference(refName, true)
- if err != nil {
- // Local branch doesn't exist, try remote-tracking branch
- remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
- ref, err = s.repo.Reference(remoteRefName, true)
- if err != nil {
- return nil, fmt.Errorf("sessions branch not found: %w", err)
- }
- }
-
- commit, err := s.repo.CommitObject(ref.Hash())
- if err != nil {
- return nil, fmt.Errorf("failed to get commit object: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get commit tree: %w", err)
- }
-
- return tree, nil
-}
-
-// CreateBlobFromContent creates a blob object from in-memory content.
-// Exported for use by strategy package (session_test.go)
-func CreateBlobFromContent(repo *git.Repository, content []byte) (plumbing.Hash, error) {
- obj := repo.Storer.NewEncodedObject()
- obj.SetType(plumbing.BlobObject)
- obj.SetSize(int64(len(content)))
-
- writer, err := obj.Writer()
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to get object writer: %w", err)
- }
-
- _, err = writer.Write(content)
- if err != nil {
- _ = writer.Close()
- return plumbing.ZeroHash, fmt.Errorf("failed to write blob content: %w", err)
- }
- if err := writer.Close(); err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to close blob writer: %w", err)
- }
-
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to store blob object: %w", err)
- }
- return hash, nil
-}
-
-// copyMetadataDir copies all files from a directory to the checkpoint path.
-// Used to include additional metadata files like task checkpoints, subagent transcripts, etc.
-func (s *GitStore) copyMetadataDir(metadataDir, basePath string, entries map[string]object.TreeEntry) error {
- err := filepath.Walk(metadataDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- // Skip symlinks to prevent reading files outside the metadata directory.
- // A symlink could point to sensitive files (e.g., /etc/passwd) which would
- // then be captured in the checkpoint and stored in git history.
- // NOTE: filepath.Walk uses os.Stat (follows symlinks), so info.Mode() never
- // reports ModeSymlink. We use os.Lstat to check the entry itself.
- // This check MUST come before IsDir() because Walk follows symlinked
- // directories and would recurse into them otherwise.
- linfo, lstatErr := os.Lstat(path)
- if lstatErr != nil {
- return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
- }
- if linfo.Mode()&os.ModeSymlink != 0 {
- if info.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
-
- if info.IsDir() {
- return nil
- }
-
- // Get relative path within metadata dir
- relPath, err := filepath.Rel(metadataDir, path)
- if err != nil {
- return fmt.Errorf("failed to get relative path for %s: %w", path, err)
- }
-
- // Prevent path traversal via symlinks pointing outside the metadata dir
- if strings.HasPrefix(relPath, "..") {
- return fmt.Errorf("path traversal detected: %s", relPath)
- }
-
- // Create blob from file with secrets redaction
- blobHash, mode, err := createRedactedBlobFromFile(s.repo, path, relPath)
- if err != nil {
- return fmt.Errorf("failed to create blob for %s: %w", path, err)
- }
-
- // Store at checkpoint path (use forward slashes for git tree compatibility on Windows)
- fullPath := basePath + filepath.ToSlash(relPath)
- entries[fullPath] = object.TreeEntry{
- Name: fullPath,
- Mode: mode,
- Hash: blobHash,
- }
-
- return nil
- })
- if err != nil {
- return fmt.Errorf("failed to walk metadata directory: %w", err)
- }
- return nil
-}
-
-// createRedactedBlobFromFile reads a file, applies secrets redaction, and creates a git blob.
-// JSONL files get JSONL-aware redaction; all other files get plain string redaction.
-func createRedactedBlobFromFile(repo *git.Repository, filePath, treePath string) (plumbing.Hash, filemode.FileMode, error) {
- info, err := os.Stat(filePath)
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to stat file: %w", err)
- }
-
- mode := filemode.Regular
- if info.Mode()&0o111 != 0 {
- mode = filemode.Executable
- }
-
- content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the metadata directory
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err)
- }
-
- // Skip redaction for binary files — they can't contain text secrets and
- // running string replacement on them would corrupt the data.
- isBin, binErr := binary.IsBinary(bytes.NewReader(content))
- if binErr != nil || isBin {
- hash, err := CreateBlobFromContent(repo, content)
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to create blob: %w", err)
- }
- return hash, mode, nil
- }
-
- if strings.HasSuffix(treePath, ".jsonl") {
- redacted, jsonlErr := redact.JSONLBytes(content)
- if jsonlErr != nil {
- content = redact.Bytes(content)
- } else {
- content = redacted.Bytes()
- }
- } else {
- content = redact.Bytes(content)
- }
-
- hash, err := CreateBlobFromContent(repo, content)
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to create blob: %w", err)
- }
- return hash, mode, nil
-}
-
-// GetGitAuthorFromRepo retrieves the git user.name and user.email,
-// checking both the repository-local config and the global ~/.gitconfig.
-func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
- // ConfigScoped merges local + global (local wins), matching git's own resolution.
- // Requires a ConfigLoader plugin to be registered; the hawk binary blank-imports
- // go-git/v6/x/plugin to register the default Auto loader.
- if cfg, err := repo.ConfigScoped(config.GlobalScope); err == nil {
- name = cfg.User.Name
- email = cfg.User.Email
- }
-
- // If not found in local config, try global config
- if name == "" || email == "" {
- //lint:ignore SA1019 // the v6 is not yet released, revisit once it is.
- globalCfg, err := config.LoadConfig(config.GlobalScope)
- if err == nil {
- if name == "" {
- name = globalCfg.User.Name
- }
- if email == "" {
- email = globalCfg.User.Email
- }
- }
- }
-
- // Provide sensible defaults if git user is not configured
- if name == "" {
- name = "Unknown"
- }
- if email == "" {
- email = "unknown@local"
- }
-
- return name, email
-}
-
-// CreateCommit creates a git commit object with the given tree, parent, message, and author.
-// If parentHash is ZeroHash, the commit is created without a parent (orphan commit).
-func CreateCommit(ctx context.Context, repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) {
- now := time.Now()
- sig := object.Signature{
- Name: authorName,
- Email: authorEmail,
- When: now,
- }
-
- commit := &object.Commit{
- TreeHash: treeHash,
- Author: sig,
- Committer: sig,
- Message: message,
- }
-
- if parentHash != plumbing.ZeroHash {
- commit.ParentHashes = []plumbing.Hash{parentHash}
- }
-
- SignCommitBestEffort(ctx, commit)
-
- obj := repo.Storer.NewEncodedObject()
- if err := commit.Encode(obj); err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err)
- }
-
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err)
- }
-
- return hash, nil
-}
-
-// SignCommitBestEffort signs the commit using an on-demand object signer.
-// If signing is disabled, no signer can be created, or signing fails, the commit
-// is left unsigned and the error is logged.
-func SignCommitBestEffort(ctx context.Context, commit *object.Commit) {
- if !settings.IsSignCheckpointCommitsEnabled(ctx) {
- return
- }
-
- signer, ok := objectSignerLoader(ctx)
- if !ok {
- return
- }
-
- if signer == nil {
- return
- }
-
- encoded := &plumbing.MemoryObject{}
- var err error
- if err = commit.EncodeWithoutSignature(encoded); err != nil {
- logging.Warn(ctx, "failed to encode commit for signing", slog.String("error", err.Error()))
- return
- }
-
- r, err := encoded.Reader()
- if err != nil {
- logging.Warn(ctx, "failed to read encoded commit", slog.String("error", err.Error()))
- return
- }
- defer r.Close()
-
- sig, err := signer.Sign(r)
- if err != nil {
- logging.Warn(ctx, "failed to sign commit", slog.String("error", err.Error()))
- return
- }
-
- commit.Signature = string(sig)
-}
-
-// readTranscriptFromTree reads a transcript from a git tree, handling both chunked and non-chunked formats.
-// It checks for chunk files first (.001, .002, etc.), then falls back to the base file.
-// The agentType is used for reassembling chunks in the correct format.
-func readTranscriptFromTree(ctx context.Context, tree *FetchingTree, agentType types.AgentType) ([]byte, error) {
- // Collect all transcript-related files
- var chunkFiles []string
- var hasBaseFile bool
-
- for _, entry := range tree.RawEntries() {
- if entry.Name == paths.TranscriptFileName || entry.Name == paths.TranscriptFileNameLegacy {
- hasBaseFile = true
- }
- // Check for chunk files (full.jsonl.001, full.jsonl.002, etc.)
- if strings.HasPrefix(entry.Name, paths.TranscriptFileName+".") {
- idx := agent.ParseChunkIndex(entry.Name, paths.TranscriptFileName)
- if idx > 0 {
- chunkFiles = append(chunkFiles, entry.Name)
- }
- }
- }
-
- // If we have chunk files, read and reassemble them
- if len(chunkFiles) > 0 {
- // Sort chunk files by index
- chunkFiles = agent.SortChunkFiles(chunkFiles, paths.TranscriptFileName)
-
- // Check if base file should be included as chunk 0.
- // NOTE: This assumes the chunking convention where the unsuffixed file
- // (full.jsonl) is chunk 0, and numbered files (.001, .002) are chunks 1+.
- if hasBaseFile {
- chunkFiles = append([]string{paths.TranscriptFileName}, chunkFiles...)
- }
-
- var chunks [][]byte
- for _, chunkFile := range chunkFiles {
- file, err := tree.File(chunkFile)
- if err != nil {
- logging.Warn(
- ctx, "failed to read transcript chunk file from tree",
- slog.String("chunk_file", chunkFile),
- slog.String("error", err.Error()),
- )
- continue
- }
- content, err := file.Contents()
- if err != nil {
- logging.Warn(
- ctx, "failed to read transcript chunk contents",
- slog.String("chunk_file", chunkFile),
- slog.String("error", err.Error()),
- )
- continue
- }
- chunks = append(chunks, []byte(content))
- }
-
- if len(chunks) > 0 {
- result, err := agent.ReassembleTranscript(chunks, agentType)
- if err != nil {
- return nil, fmt.Errorf("failed to reassemble transcript: %w", err)
- }
- return result, nil
- }
- }
-
- // Fall back to reading base file (non-chunked or backwards compatibility)
- if file, err := tree.File(paths.TranscriptFileName); err == nil {
- if content, err := file.Contents(); err == nil {
- return []byte(content), nil
- }
- }
-
- // Try legacy filename
- if file, err := tree.File(paths.TranscriptFileNameLegacy); err == nil {
- if content, err := file.Contents(); err == nil {
- return []byte(content), nil
- }
- }
-
- return nil, nil
-}
-
-// Author contains author information for a checkpoint.
-type Author struct {
- Name string
- Email string
-}
-
-// GetCheckpointAuthor retrieves the author of a checkpoint from the trace/checkpoints/v1 commit history.
-// Finds the commit whose subject matches "Checkpoint: " and returns its author.
-// Returns empty Author if the checkpoint is not found or the sessions branch doesn't exist.
-func (s *GitStore) GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error) {
- StorerMu.Lock()
- defer StorerMu.Unlock()
-
- if err := ctx.Err(); err != nil {
- return Author{}, err //nolint:wrapcheck // Propagating context cancellation
- }
-
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := s.repo.Reference(refName, true)
- if err != nil {
- return Author{}, nil
- }
-
- // Search for the commit whose subject matches "Checkpoint: "
- targetSubject := "Checkpoint: " + checkpointID.String()
-
- iter, err := s.repo.Log(&git.LogOptions{
- From: ref.Hash(),
- Order: git.LogOrderCommitterTime,
- })
- if err != nil {
- return Author{}, nil
- }
- defer iter.Close()
-
- var author Author
- err = iter.ForEach(func(c *object.Commit) error {
- if err := ctx.Err(); err != nil {
- return err //nolint:wrapcheck // Propagating context cancellation
- }
- subject := strings.SplitN(c.Message, "\n", 2)[0]
- if subject == targetSubject {
- author = Author{
- Name: c.Author.Name,
- Email: c.Author.Email,
- }
- return errStopIteration
- }
- return nil
- })
-
- if err != nil && !errors.Is(err, errStopIteration) {
- return Author{}, nil
- }
-
- return author, nil
-}
diff --git a/cli/checkpoint/committed_2.go b/cli/checkpoint/committed_2.go
new file mode 100644
index 0000000..19dd04a
--- /dev/null
+++ b/cli/checkpoint/committed_2.go
@@ -0,0 +1,828 @@
+package checkpoint
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/jsonutil"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/GrayCodeAI/trace/cli/vercelconfig"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// redactCodeLearnings redacts only the Finding field, preserving Path/Line/EndLine.
+func redactCodeLearnings(cls []CodeLearning) []CodeLearning {
+ if cls == nil {
+ return nil
+ }
+ out := make([]CodeLearning, len(cls))
+ for i, cl := range cls {
+ out[i] = CodeLearning{
+ Path: cl.Path,
+ Line: cl.Line,
+ EndLine: cl.EndLine,
+ Finding: redact.String(cl.Finding),
+ }
+ }
+ return out
+}
+
+// readMetadataFromBlob reads CommittedMetadata from a blob hash.
+func (s *GitStore) readMetadataFromBlob(hash plumbing.Hash) (*CommittedMetadata, error) {
+ return readJSONFromBlob[CommittedMetadata](s.repo, hash)
+}
+
+// buildCommitMessage constructs the commit message with proper trailers.
+// The commit subject is always "Checkpoint: " for consistency.
+// If CommitSubject is provided (e.g., for task checkpoints), it's included in the body.
+func (s *GitStore) buildCommitMessage(opts WriteCommittedOptions, taskMetadataPath string) string {
+ var commitMsg strings.Builder
+
+ // Subject line is always the checkpoint ID for consistent formatting
+ fmt.Fprintf(&commitMsg, "Checkpoint: %s\n\n", opts.CheckpointID)
+
+ // Include custom description in body if provided (e.g., task checkpoint details)
+ if opts.CommitSubject != "" {
+ commitMsg.WriteString(opts.CommitSubject + "\n\n")
+ }
+ fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.SessionTrailerKey, opts.SessionID)
+ fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.StrategyTrailerKey, opts.Strategy)
+ if opts.Agent != "" {
+ fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.AgentTrailerKey, opts.Agent)
+ }
+ if opts.EphemeralBranch != "" {
+ fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.EphemeralBranchTrailerKey, opts.EphemeralBranch)
+ }
+ if taskMetadataPath != "" {
+ fmt.Fprintf(&commitMsg, "%s: %s\n", trailers.MetadataTaskTrailerKey, taskMetadataPath)
+ }
+
+ return commitMsg.String()
+}
+
+// incrementalCheckpointData represents an incremental checkpoint during subagent execution.
+// This mirrors strategy.SubagentCheckpoint but avoids import cycles.
+type incrementalCheckpointData struct {
+ Type string `json:"type"`
+ ToolUseID string `json:"tool_use_id"`
+ Timestamp time.Time `json:"timestamp"`
+ Data json.RawMessage `json:"data"`
+}
+
+// taskCheckpointData represents a final task checkpoint.
+// This mirrors strategy.TaskCheckpoint but avoids import cycles.
+type taskCheckpointData struct {
+ SessionID string `json:"session_id"`
+ ToolUseID string `json:"tool_use_id"`
+ CheckpointUUID string `json:"checkpoint_uuid"`
+ AgentID string `json:"agent_id,omitempty"`
+}
+
+// ReadCommitted reads a committed checkpoint's summary by ID from the trace/checkpoints/v1 branch.
+// Returns only the CheckpointSummary (paths + aggregated stats), not actual content.
+// Use ReadSessionContent to read actual transcript/prompts/context.
+// Returns nil, nil if the checkpoint doesn't exist.
+//
+// The storage format uses numbered subdirectories for each session (0-based):
+//
+// /
+// ├── metadata.json # CheckpointSummary with sessions map
+// ├── 0/ # First session
+// │ ├── metadata.json # Session-specific metadata
+// │ └── full.jsonl # Transcript
+// ├── 1/ # Second session
+// └── ...
+func (s *GitStore) ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ return s.readCommitted(ctx, checkpointID)
+}
+
+// readCommitted is the unlocked internal implementation. Callers must hold storerMu.
+func (s *GitStore) readCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ ft, err := s.getFetchingTree(ctx)
+ if err != nil {
+ return nil, nil //nolint:nilnil,nilerr // No sessions branch means no checkpoint exists
+ }
+
+ checkpointPath := checkpointID.Path()
+ checkpointTree, err := ft.Tree(checkpointPath)
+ if err != nil {
+ return nil, nil //nolint:nilnil,nilerr // Checkpoint directory not found
+ }
+
+ // Read root metadata.json as CheckpointSummary (auto-fetches blob if needed)
+ metadataFile, err := checkpointTree.File(paths.MetadataFileName)
+ if err != nil {
+ return nil, nil //nolint:nilnil,nilerr // metadata.json not found
+ }
+
+ content, err := metadataFile.Contents()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read metadata.json: %w", err)
+ }
+
+ var summary CheckpointSummary
+ if err := json.Unmarshal([]byte(content), &summary); err != nil {
+ return nil, fmt.Errorf("failed to parse metadata.json: %w", err)
+ }
+
+ return &summary, nil
+}
+
+// ReadSessionMetadata reads only the metadata.json for a specific session within a checkpoint.
+// This is a lightweight read that avoids fetching transcript/prompt blobs.
+// sessionIndex is 0-based.
+func (s *GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ if err := ctx.Err(); err != nil {
+ return nil, err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ ft, err := s.getFetchingTree(ctx)
+ if err != nil {
+ return nil, ErrCheckpointNotFound
+ }
+
+ checkpointPath := checkpointID.Path()
+ sessionPath := fmt.Sprintf("%s/%d", checkpointPath, sessionIndex)
+ sessionTree, err := ft.Tree(sessionPath)
+ if err != nil {
+ return nil, fmt.Errorf("%w: session %d not found: %w", ErrCheckpointNotFound, sessionIndex, err)
+ }
+
+ metadataFile, err := sessionTree.File(paths.MetadataFileName)
+ if err != nil {
+ return nil, fmt.Errorf("metadata.json not found for session %d: %w", sessionIndex, err)
+ }
+
+ content, err := metadataFile.Contents()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read session metadata: %w", err)
+ }
+
+ var metadata CommittedMetadata
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ return nil, fmt.Errorf("failed to parse session metadata: %w", err)
+ }
+
+ return &metadata, nil
+}
+
+// ReadSessionContent reads the actual content for a specific session within a checkpoint.
+// sessionIndex is 0-based (0 for first session, 1 for second, etc.).
+// Returns the session's metadata, transcript, prompts, and context.
+// Returns ErrCheckpointNotFound if the checkpoint or session doesn't exist.
+// Returns ErrNoTranscript if the session exists but has no transcript.
+func (s *GitStore) ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ return s.readSessionContent(ctx, checkpointID, sessionIndex)
+}
+
+// readSessionContent is the unlocked internal implementation. Callers must hold storerMu.
+func (s *GitStore) readSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ ft, err := s.getFetchingTree(ctx)
+ if err != nil {
+ return nil, ErrCheckpointNotFound
+ }
+
+ checkpointPath := checkpointID.Path()
+ checkpointTree, err := ft.Tree(checkpointPath)
+ if err != nil {
+ return nil, ErrCheckpointNotFound
+ }
+
+ // Get the session subdirectory
+ sessionDir := strconv.Itoa(sessionIndex)
+ sessionTree, err := checkpointTree.Tree(sessionDir)
+ if err != nil {
+ return nil, fmt.Errorf("%w: session %d not found: %w", ErrCheckpointNotFound, sessionIndex, err)
+ }
+
+ result := &SessionContent{}
+
+ // Read session-specific metadata (auto-fetches blob if needed)
+ var agentType types.AgentType
+ if metadataFile, fileErr := sessionTree.File(paths.MetadataFileName); fileErr == nil {
+ if content, contentErr := metadataFile.Contents(); contentErr == nil {
+ if jsonErr := json.Unmarshal([]byte(content), &result.Metadata); jsonErr == nil {
+ agentType = result.Metadata.Agent
+ }
+ }
+ }
+
+ // Read transcript (auto-fetches blobs if needed)
+ if transcript, transcriptErr := readTranscriptFromTree(ctx, sessionTree, agentType); transcriptErr == nil && transcript != nil {
+ result.Transcript = transcript
+ }
+
+ // Read prompts (auto-fetches blob if needed)
+ if file, fileErr := sessionTree.File(paths.PromptFileName); fileErr == nil {
+ if content, contentErr := file.Contents(); contentErr == nil {
+ result.Prompts = content
+ }
+ }
+
+ if len(result.Transcript) == 0 {
+ return nil, ErrNoTranscript
+ }
+
+ return result, nil
+}
+
+// ReadLatestSessionContent is a convenience method that reads the latest session's content.
+// This is equivalent to ReadSessionContent(ctx, checkpointID, len(summary.Sessions)-1).
+func (s *GitStore) ReadLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ return s.readLatestSessionContent(ctx, checkpointID)
+}
+
+// readLatestSessionContent is the unlocked internal implementation. Callers must hold storerMu.
+func (s *GitStore) readLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) {
+ summary, err := s.readCommitted(ctx, checkpointID)
+ if err != nil {
+ return nil, err
+ }
+ if summary == nil {
+ return nil, ErrCheckpointNotFound
+ }
+ if len(summary.Sessions) == 0 {
+ return nil, fmt.Errorf("checkpoint has no sessions: %s", checkpointID)
+ }
+
+ latestIndex := len(summary.Sessions) - 1
+ return s.readSessionContent(ctx, checkpointID, latestIndex)
+}
+
+// ReadSessionContentByID reads a session's content by its session ID.
+// This is useful when you have the session ID but don't know its index within the checkpoint.
+// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
+// Returns an error if no session with the given ID exists in the checkpoint.
+func (s *GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ summary, err := s.readCommitted(ctx, checkpointID)
+ if err != nil {
+ return nil, err
+ }
+ if summary == nil {
+ return nil, ErrCheckpointNotFound
+ }
+
+ // Iterate through sessions to find the one with matching session ID
+ for i := range len(summary.Sessions) {
+ content, readErr := s.readSessionContent(ctx, checkpointID, i)
+ if readErr != nil {
+ continue
+ }
+ if content != nil && content.Metadata.SessionID == sessionID {
+ return content, nil
+ }
+ }
+
+ return nil, fmt.Errorf("session %q not found in checkpoint %s", sessionID, checkpointID)
+}
+
+// ListCommitted lists all committed checkpoints from the trace/checkpoints/v1 branch.
+// Scans sharded paths: // directories containing metadata.json.
+//
+
+func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ if err := ctx.Err(); err != nil {
+ return nil, err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ tree, err := s.getSessionsBranchTree()
+ if err != nil {
+ return []CommittedInfo{}, nil //nolint:nilerr // No sessions branch means empty list
+ }
+
+ var checkpoints []CommittedInfo
+
+ // Scan sharded structure: <2-char-prefix>//metadata.json
+ _ = WalkCheckpointShards(s.repo, tree, func(checkpointID id.CheckpointID, cpTreeHash plumbing.Hash) error { //nolint:errcheck // callback never returns errors
+ checkpointTree, cpTreeErr := s.repo.TreeObject(cpTreeHash)
+ if cpTreeErr != nil {
+ return nil //nolint:nilerr // skip unreadable entries, continue walking
+ }
+
+ info := CommittedInfo{
+ CheckpointID: checkpointID,
+ }
+
+ // Get details from root metadata file (CheckpointSummary format)
+ if metadataFile, fileErr := checkpointTree.File(paths.MetadataFileName); fileErr == nil {
+ if content, contentErr := metadataFile.Contents(); contentErr == nil {
+ var summary CheckpointSummary
+ if err := json.Unmarshal([]byte(content), &summary); err == nil {
+ info.CheckpointsCount = summary.CheckpointsCount
+ info.FilesTouched = summary.FilesTouched
+ info.SessionCount = len(summary.Sessions)
+
+ // Read session metadata from latest session to get Agent, SessionID, CreatedAt
+ if len(summary.Sessions) > 0 {
+ latestIndex := len(summary.Sessions) - 1
+ latestDir := strconv.Itoa(latestIndex)
+ if sessionTree, treeErr := checkpointTree.Tree(latestDir); treeErr == nil {
+ if sessionMetadataFile, smErr := sessionTree.File(paths.MetadataFileName); smErr == nil {
+ if sessionContent, scErr := sessionMetadataFile.Contents(); scErr == nil {
+ var sessionMetadata CommittedMetadata
+ if json.Unmarshal([]byte(sessionContent), &sessionMetadata) == nil {
+ info.Agent = sessionMetadata.Agent
+ info.SessionID = sessionMetadata.SessionID
+ info.CreatedAt = sessionMetadata.CreatedAt
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ checkpoints = append(checkpoints, info)
+ return nil
+ })
+
+ // Sort by time (most recent first)
+ sort.Slice(checkpoints, func(i, j int) bool {
+ return checkpoints[i].CreatedAt.After(checkpoints[j].CreatedAt)
+ })
+
+ return checkpoints, nil
+}
+
+// GetTranscript retrieves the transcript for a specific checkpoint ID.
+// Returns the latest session's transcript.
+func (s *GitStore) GetTranscript(ctx context.Context, checkpointID id.CheckpointID) ([]byte, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ content, err := s.readLatestSessionContent(ctx, checkpointID)
+ if err != nil {
+ return nil, err
+ }
+ if len(content.Transcript) == 0 {
+ return nil, fmt.Errorf("no transcript found for checkpoint: %s", checkpointID)
+ }
+ return content.Transcript, nil
+}
+
+// GetSessionLog retrieves the session transcript and session ID for a checkpoint.
+// This is the primary method for looking up session logs by checkpoint ID.
+// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
+// Returns ErrNoTranscript if the checkpoint exists but has no transcript.
+func (s *GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ content, err := s.readLatestSessionContent(ctx, cpID)
+ if err != nil {
+ return nil, "", err
+ }
+ return content.Transcript, content.Metadata.SessionID, nil
+}
+
+// LookupSessionLog is a convenience function that opens the repository and retrieves
+// a session log by checkpoint ID. This is the primary entry point for callers that
+// don't already have a GitStore instance.
+// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
+// Returns ErrNoTranscript if the checkpoint exists but has no transcript.
+func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
+ repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to open git repository: %w", err)
+ }
+ store := NewGitStore(repo)
+ return store.GetSessionLog(ctx, cpID)
+}
+
+// UpdateSummary updates the summary field in the latest session's metadata.
+// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
+func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *Summary) error {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ if err := ctx.Err(); err != nil {
+ return err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ // Ensure sessions branch exists
+ if err := s.ensureSessionsBranch(ctx); err != nil {
+ return fmt.Errorf("failed to ensure sessions branch: %w", err)
+ }
+
+ // Get branch ref and root tree hash (O(1), no flatten)
+ parentHash, rootTreeHash, err := s.getSessionsBranchRef()
+ if err != nil {
+ return err
+ }
+
+ // Flatten only the checkpoint subtree
+ basePath := checkpointID.Path() + "/"
+ checkpointPath := checkpointID.Path()
+ entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath)
+ if err != nil {
+ return err
+ }
+
+ // Read root CheckpointSummary to find the latest session
+ rootMetadataPath := basePath + paths.MetadataFileName
+ entry, exists := entries[rootMetadataPath]
+ if !exists {
+ return ErrCheckpointNotFound
+ }
+
+ checkpointSummary, err := s.readSummaryFromBlob(entry.Hash)
+ if err != nil {
+ return fmt.Errorf("failed to read checkpoint summary: %w", err)
+ }
+
+ // Find the latest session's metadata path (0-based indexing)
+ latestIndex := len(checkpointSummary.Sessions) - 1
+ sessionMetadataPath := fmt.Sprintf("%s%d/%s", basePath, latestIndex, paths.MetadataFileName)
+ sessionEntry, exists := entries[sessionMetadataPath]
+ if !exists {
+ return fmt.Errorf("session metadata not found at %s", sessionMetadataPath)
+ }
+
+ // Read and update session metadata
+ existingMetadata, err := s.readMetadataFromBlob(sessionEntry.Hash)
+ if err != nil {
+ return fmt.Errorf("failed to read session metadata: %w", err)
+ }
+
+ // Update the summary
+ existingMetadata.Summary = redactSummary(summary)
+
+ // Write updated session metadata
+ metadataJSON, err := jsonutil.MarshalIndentWithNewline(existingMetadata, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal metadata: %w", err)
+ }
+ metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON)
+ if err != nil {
+ return fmt.Errorf("failed to create metadata blob: %w", err)
+ }
+ entries[sessionMetadataPath] = object.TreeEntry{
+ Name: sessionMetadataPath,
+ Mode: filemode.Regular,
+ Hash: metadataHash,
+ }
+
+ // Build checkpoint subtree and splice into root (O(depth) tree surgery)
+ newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries)
+ if err != nil {
+ return err
+ }
+
+ authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
+ commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID)
+ newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail)
+ if err != nil {
+ return err
+ }
+
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ newRef := plumbing.NewHashReference(refName, newCommitHash)
+ if err := s.repo.Storer.SetReference(newRef); err != nil {
+ return fmt.Errorf("failed to set branch reference: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateCommitted replaces the transcript, prompts, and context for an existing
+// committed checkpoint. Uses replace semantics: the full session transcript is
+// written, replacing whatever was stored at initial condensation time.
+//
+// This is called at stop time to finalize all checkpoints from the current turn
+// with the complete session transcript (from prompt to stop event).
+//
+// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
+func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ if opts.CheckpointID.IsEmpty() {
+ return errors.New("invalid update options: checkpoint ID is required")
+ }
+
+ // Ensure sessions branch exists
+ if err := s.ensureSessionsBranch(ctx); err != nil {
+ return fmt.Errorf("failed to ensure sessions branch: %w", err)
+ }
+
+ // Get branch ref and root tree hash (O(1), no flatten)
+ parentHash, rootTreeHash, err := s.getSessionsBranchRef()
+ if err != nil {
+ return err
+ }
+
+ // Flatten only the checkpoint subtree
+ basePath := opts.CheckpointID.Path() + "/"
+ checkpointPath := opts.CheckpointID.Path()
+ entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath)
+ if err != nil {
+ return err
+ }
+
+ // Read root CheckpointSummary to find the session slot
+ rootMetadataPath := basePath + paths.MetadataFileName
+ entry, exists := entries[rootMetadataPath]
+ if !exists {
+ return ErrCheckpointNotFound
+ }
+
+ checkpointSummary, err := s.readSummaryFromBlob(entry.Hash)
+ if err != nil {
+ return fmt.Errorf("failed to read checkpoint summary: %w", err)
+ }
+ if len(checkpointSummary.Sessions) == 0 {
+ return ErrCheckpointNotFound
+ }
+
+ // Find session index matching opts.SessionID
+ sessionIndex := -1
+ for i := range len(checkpointSummary.Sessions) {
+ metaPath := fmt.Sprintf("%s%d/%s", basePath, i, paths.MetadataFileName)
+ if metaEntry, metaExists := entries[metaPath]; metaExists {
+ meta, metaErr := s.readMetadataFromBlob(metaEntry.Hash)
+ if metaErr == nil && meta.SessionID == opts.SessionID {
+ sessionIndex = i
+ break
+ }
+ }
+ }
+ if sessionIndex == -1 {
+ // Fall back to latest session; log so mismatches are diagnosable.
+ sessionIndex = len(checkpointSummary.Sessions) - 1
+ logging.Debug(
+ ctx, "UpdateCommitted: session ID not found, falling back to latest",
+ slog.String("session_id", opts.SessionID),
+ slog.String("checkpoint_id", string(opts.CheckpointID)),
+ slog.Int("fallback_index", sessionIndex),
+ )
+ }
+
+ sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex)
+
+ // Replace transcript (full replace, not append).
+ // Transcript is pre-redacted by the caller (enforced by RedactedBytes type).
+ if opts.Transcript.Len() > 0 {
+ if err := s.replaceTranscript(ctx, opts.Transcript, opts.Agent, opts.PrecomputedBlobs, sessionPath, entries); err != nil {
+ return fmt.Errorf("failed to replace transcript: %w", err)
+ }
+ }
+
+ // Replace prompts (apply redaction as safety net)
+ if len(opts.Prompts) > 0 {
+ promptContent := redact.String(JoinPrompts(opts.Prompts))
+ blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
+ if err != nil {
+ return fmt.Errorf("failed to create prompt blob: %w", err)
+ }
+ entries[sessionPath+paths.PromptFileName] = object.TreeEntry{
+ Name: sessionPath + paths.PromptFileName,
+ Mode: filemode.Regular,
+ Hash: blobHash,
+ }
+ }
+
+ // Build checkpoint subtree and splice into root (O(depth) tree surgery)
+ newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries)
+ if err != nil {
+ return err
+ }
+ newTreeHash, err = s.maybeMergeVercelConfig(ctx, newTreeHash)
+ if err != nil {
+ return err
+ }
+
+ authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
+ commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID)
+ newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail)
+ if err != nil {
+ return err
+ }
+
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ newRef := plumbing.NewHashReference(refName, newCommitHash)
+ if err := s.repo.Storer.SetReference(newRef); err != nil {
+ return fmt.Errorf("failed to set branch reference: %w", err)
+ }
+
+ return nil
+}
+
+// replaceTranscript writes the full transcript content, replacing any existing transcript.
+// Also removes any chunk files from a previous write and updates the content hash.
+//
+// Short-circuits when the existing content_hash.txt already matches the new
+// transcript's sha256 — in that case the chunk entries are preserved as-is and
+// no chunking/zlib happens. Use precomputed (non-nil) to reuse blob hashes
+// computed once across multiple checkpoints.
+func (s *GitStore) replaceTranscript(ctx context.Context, transcript redact.RedactedBytes, agentType types.AgentType, precomputed *PrecomputedTranscriptBlobs, sessionPath string, entries map[string]object.TreeEntry) error {
+ // Ignore precompute if invariants are violated — fall back to fresh chunking.
+ if precomputed != nil && !precomputed.isUsable() {
+ precomputed = nil
+ }
+
+ // Compute the new content-hash string (cheap — SHA-256 over transcript bytes).
+ var newContentHash string
+ if precomputed != nil {
+ newContentHash = precomputed.ContentHash
+ } else {
+ newContentHash = fmt.Sprintf("sha256:%x", sha256.Sum256(transcript.Bytes()))
+ }
+
+ // Short-circuit: if the existing content_hash.txt already matches, the
+ // chunk entries currently in `entries` represent the same content. Leave
+ // everything as-is and skip chunking + zlib.
+ hashPath := sessionPath + paths.ContentHashFileName
+ if existing, ok := entries[hashPath]; ok {
+ if blob, err := s.repo.BlobObject(existing.Hash); err == nil {
+ if rdr, rerr := blob.Reader(); rerr == nil {
+ existingHash, readErr := io.ReadAll(rdr)
+ _ = rdr.Close()
+ if readErr == nil && string(existingHash) == newContentHash {
+ return nil
+ }
+ }
+ }
+ }
+
+ // Remove existing transcript files (base + any chunks)
+ transcriptBase := sessionPath + paths.TranscriptFileName
+ for key := range entries {
+ if key == transcriptBase || strings.HasPrefix(key, transcriptBase+".") {
+ delete(entries, key)
+ }
+ }
+
+ // Resolve chunk hashes from precompute, or chunk + blob-write now.
+ var chunkHashes []plumbing.Hash
+ if precomputed != nil {
+ chunkHashes = precomputed.ChunkHashes
+ } else {
+ chunks, err := chunkTranscript(ctx, transcript.Bytes(), agentType)
+ if err != nil {
+ return fmt.Errorf("failed to chunk transcript: %w", err)
+ }
+ chunkHashes = make([]plumbing.Hash, len(chunks))
+ for i, chunk := range chunks {
+ blobHash, err := CreateBlobFromContent(s.repo, chunk)
+ if err != nil {
+ return fmt.Errorf("failed to create transcript blob: %w", err)
+ }
+ chunkHashes[i] = blobHash
+ }
+ }
+
+ // Record chunk files in the tree at v1 (full.jsonl) naming.
+ for i, blobHash := range chunkHashes {
+ chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i)
+ entries[chunkPath] = object.TreeEntry{
+ Name: chunkPath,
+ Mode: filemode.Regular,
+ Hash: blobHash,
+ }
+ }
+
+ // Content-hash blob.
+ var hashBlob plumbing.Hash
+ if precomputed != nil {
+ hashBlob = precomputed.ContentHashBlob
+ } else {
+ h, err := CreateBlobFromContent(s.repo, []byte(newContentHash))
+ if err != nil {
+ return fmt.Errorf("failed to create content hash blob: %w", err)
+ }
+ hashBlob = h
+ }
+ entries[hashPath] = object.TreeEntry{
+ Name: hashPath,
+ Mode: filemode.Regular,
+ Hash: hashBlob,
+ }
+
+ return nil
+}
+
+// PrecomputeTranscriptBlobs chunks the given transcript and writes each chunk
+// plus the content-hash blob to the object store once, returning the resulting
+// hashes for reuse across multiple UpdateCommitted calls that share the same
+// transcript content.
+//
+// The returned blobs work for both v1 (full.jsonl) and v2 (raw_transcript)
+// paths since blob hashes are content-addressed (SHA-1 of chunk bytes). Only
+// the tree-entry filenames differ between v1 and v2.
+func PrecomputeTranscriptBlobs(ctx context.Context, repo *git.Repository, transcript redact.RedactedBytes, agentType types.AgentType) (*PrecomputedTranscriptBlobs, error) {
+ raw := transcript.Bytes()
+
+ chunks, err := chunkTranscript(ctx, raw, agentType)
+ if err != nil {
+ return nil, fmt.Errorf("failed to chunk transcript: %w", err)
+ }
+
+ chunkHashes := make([]plumbing.Hash, len(chunks))
+ for i, chunk := range chunks {
+ h, err := CreateBlobFromContent(repo, chunk)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create transcript blob: %w", err)
+ }
+ chunkHashes[i] = h
+ }
+
+ contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(raw))
+ hashBlob, err := CreateBlobFromContent(repo, []byte(contentHash))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create content hash blob: %w", err)
+ }
+
+ return &PrecomputedTranscriptBlobs{
+ ChunkHashes: chunkHashes,
+ ContentHashBlob: hashBlob,
+ ContentHash: contentHash,
+ }, nil
+}
+
+// ensureSessionsBranch ensures the trace/checkpoints/v1 branch exists.
+func (s *GitStore) ensureSessionsBranch(ctx context.Context) error {
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ _, err := s.repo.Reference(refName, true)
+ if err == nil {
+ return nil // Branch exists
+ }
+
+ // Create orphan branch with empty tree
+ emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry))
+ if err != nil {
+ return err
+ }
+ emptyTreeHash, err = s.maybeMergeVercelConfig(ctx, emptyTreeHash)
+ if err != nil {
+ return err
+ }
+
+ authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
+ commitHash, err := s.createCommit(ctx, emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail)
+ if err != nil {
+ return err
+ }
+
+ newRef := plumbing.NewHashReference(refName, commitHash)
+ if err := s.repo.Storer.SetReference(newRef); err != nil {
+ return fmt.Errorf("failed to set branch reference: %w", err)
+ }
+ return nil
+}
+
+func (s *GitStore) maybeMergeVercelConfig(ctx context.Context, rootTreeHash plumbing.Hash) (plumbing.Hash, error) {
+ if err := vercelconfig.InitSettings(ctx); err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("initialize vercel settings: %w", err)
+ }
+ mergedTreeHash, err := vercelconfig.MaybeMergeMetadataBranchConfig(s.repo, rootTreeHash)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("merge vercel metadata branch config: %w", err)
+ }
+ return mergedTreeHash, nil
+}
diff --git a/cli/checkpoint/committed_3.go b/cli/checkpoint/committed_3.go
new file mode 100644
index 0000000..8592adc
--- /dev/null
+++ b/cli/checkpoint/committed_3.go
@@ -0,0 +1,456 @@
+package checkpoint
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/config"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/go-git/go-git/v6/utils/binary"
+)
+
+// getFetchingTree returns a FetchingTree for the metadata branch.
+// If a blob fetcher is configured on the store, File() calls on the returned
+// tree will automatically fetch missing blobs from the remote.
+func (s *GitStore) getFetchingTree(ctx context.Context) (*FetchingTree, error) {
+ tree, err := s.getSessionsBranchTree()
+ if err != nil {
+ return nil, err
+ }
+ return NewFetchingTree(ctx, tree, s.repo.Storer, s.blobFetcher), nil
+}
+
+// getSessionsBranchTree returns the tree object for the trace/checkpoints/v1 branch.
+// Falls back to origin/trace/checkpoints/v1 if the local branch doesn't exist.
+func (s *GitStore) getSessionsBranchTree() (*object.Tree, error) {
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := s.repo.Reference(refName, true)
+ if err != nil {
+ // Local branch doesn't exist, try remote-tracking branch
+ remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
+ ref, err = s.repo.Reference(remoteRefName, true)
+ if err != nil {
+ return nil, fmt.Errorf("sessions branch not found: %w", err)
+ }
+ }
+
+ commit, err := s.repo.CommitObject(ref.Hash())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit object: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit tree: %w", err)
+ }
+
+ return tree, nil
+}
+
+// CreateBlobFromContent creates a blob object from in-memory content.
+// Exported for use by strategy package (session_test.go)
+func CreateBlobFromContent(repo *git.Repository, content []byte) (plumbing.Hash, error) {
+ obj := repo.Storer.NewEncodedObject()
+ obj.SetType(plumbing.BlobObject)
+ obj.SetSize(int64(len(content)))
+
+ writer, err := obj.Writer()
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to get object writer: %w", err)
+ }
+
+ _, err = writer.Write(content)
+ if err != nil {
+ _ = writer.Close()
+ return plumbing.ZeroHash, fmt.Errorf("failed to write blob content: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to close blob writer: %w", err)
+ }
+
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to store blob object: %w", err)
+ }
+ return hash, nil
+}
+
+// copyMetadataDir copies all files from a directory to the checkpoint path.
+// Used to include additional metadata files like task checkpoints, subagent transcripts, etc.
+func (s *GitStore) copyMetadataDir(metadataDir, basePath string, entries map[string]object.TreeEntry) error {
+ err := filepath.Walk(metadataDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip symlinks to prevent reading files outside the metadata directory.
+ // A symlink could point to sensitive files (e.g., /etc/passwd) which would
+ // then be captured in the checkpoint and stored in git history.
+ // NOTE: filepath.Walk uses os.Stat (follows symlinks), so info.Mode() never
+ // reports ModeSymlink. We use os.Lstat to check the entry itself.
+ // This check MUST come before IsDir() because Walk follows symlinked
+ // directories and would recurse into them otherwise.
+ linfo, lstatErr := os.Lstat(path)
+ if lstatErr != nil {
+ return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
+ }
+ if linfo.Mode()&os.ModeSymlink != 0 {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ if info.IsDir() {
+ return nil
+ }
+
+ // Get relative path within metadata dir
+ relPath, err := filepath.Rel(metadataDir, path)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path for %s: %w", path, err)
+ }
+
+ // Prevent path traversal via symlinks pointing outside the metadata dir
+ if strings.HasPrefix(relPath, "..") {
+ return fmt.Errorf("path traversal detected: %s", relPath)
+ }
+
+ // Create blob from file with secrets redaction
+ blobHash, mode, err := createRedactedBlobFromFile(s.repo, path, relPath)
+ if err != nil {
+ return fmt.Errorf("failed to create blob for %s: %w", path, err)
+ }
+
+ // Store at checkpoint path (use forward slashes for git tree compatibility on Windows)
+ fullPath := basePath + filepath.ToSlash(relPath)
+ entries[fullPath] = object.TreeEntry{
+ Name: fullPath,
+ Mode: mode,
+ Hash: blobHash,
+ }
+
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to walk metadata directory: %w", err)
+ }
+ return nil
+}
+
+// createRedactedBlobFromFile reads a file, applies secrets redaction, and creates a git blob.
+// JSONL files get JSONL-aware redaction; all other files get plain string redaction.
+func createRedactedBlobFromFile(repo *git.Repository, filePath, treePath string) (plumbing.Hash, filemode.FileMode, error) {
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to stat file: %w", err)
+ }
+
+ mode := filemode.Regular
+ if info.Mode()&0o111 != 0 {
+ mode = filemode.Executable
+ }
+
+ content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the metadata directory
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err)
+ }
+
+ // Skip redaction for binary files — they can't contain text secrets and
+ // running string replacement on them would corrupt the data.
+ isBin, binErr := binary.IsBinary(bytes.NewReader(content))
+ if binErr != nil || isBin {
+ hash, err := CreateBlobFromContent(repo, content)
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to create blob: %w", err)
+ }
+ return hash, mode, nil
+ }
+
+ if strings.HasSuffix(treePath, ".jsonl") {
+ redacted, jsonlErr := redact.JSONLBytes(content)
+ if jsonlErr != nil {
+ content = redact.Bytes(content)
+ } else {
+ content = redacted.Bytes()
+ }
+ } else {
+ content = redact.Bytes(content)
+ }
+
+ hash, err := CreateBlobFromContent(repo, content)
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to create blob: %w", err)
+ }
+ return hash, mode, nil
+}
+
+// GetGitAuthorFromRepo retrieves the git user.name and user.email,
+// checking both the repository-local config and the global ~/.gitconfig.
+func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
+ // ConfigScoped merges local + global (local wins), matching git's own resolution.
+ // Requires a ConfigLoader plugin to be registered; the hawk binary blank-imports
+ // go-git/v6/x/plugin to register the default Auto loader.
+ if cfg, err := repo.ConfigScoped(config.GlobalScope); err == nil {
+ name = cfg.User.Name
+ email = cfg.User.Email
+ }
+
+ // If not found in local config, try global config
+ if name == "" || email == "" {
+ //lint:ignore SA1019 // the v6 is not yet released, revisit once it is.
+ globalCfg, err := config.LoadConfig(config.GlobalScope)
+ if err == nil {
+ if name == "" {
+ name = globalCfg.User.Name
+ }
+ if email == "" {
+ email = globalCfg.User.Email
+ }
+ }
+ }
+
+ // Provide sensible defaults if git user is not configured
+ if name == "" {
+ name = "Unknown"
+ }
+ if email == "" {
+ email = "unknown@local"
+ }
+
+ return name, email
+}
+
+// CreateCommit creates a git commit object with the given tree, parent, message, and author.
+// If parentHash is ZeroHash, the commit is created without a parent (orphan commit).
+func CreateCommit(ctx context.Context, repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) {
+ now := time.Now()
+ sig := object.Signature{
+ Name: authorName,
+ Email: authorEmail,
+ When: now,
+ }
+
+ commit := &object.Commit{
+ TreeHash: treeHash,
+ Author: sig,
+ Committer: sig,
+ Message: message,
+ }
+
+ if parentHash != plumbing.ZeroHash {
+ commit.ParentHashes = []plumbing.Hash{parentHash}
+ }
+
+ SignCommitBestEffort(ctx, commit)
+
+ obj := repo.Storer.NewEncodedObject()
+ if err := commit.Encode(obj); err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err)
+ }
+
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err)
+ }
+
+ return hash, nil
+}
+
+// SignCommitBestEffort signs the commit using an on-demand object signer.
+// If signing is disabled, no signer can be created, or signing fails, the commit
+// is left unsigned and the error is logged.
+func SignCommitBestEffort(ctx context.Context, commit *object.Commit) {
+ if !settings.IsSignCheckpointCommitsEnabled(ctx) {
+ return
+ }
+
+ signer, ok := objectSignerLoader(ctx)
+ if !ok {
+ return
+ }
+
+ if signer == nil {
+ return
+ }
+
+ encoded := &plumbing.MemoryObject{}
+ var err error
+ if err = commit.EncodeWithoutSignature(encoded); err != nil {
+ logging.Warn(ctx, "failed to encode commit for signing", slog.String("error", err.Error()))
+ return
+ }
+
+ r, err := encoded.Reader()
+ if err != nil {
+ logging.Warn(ctx, "failed to read encoded commit", slog.String("error", err.Error()))
+ return
+ }
+ defer r.Close()
+
+ sig, err := signer.Sign(r)
+ if err != nil {
+ logging.Warn(ctx, "failed to sign commit", slog.String("error", err.Error()))
+ return
+ }
+
+ commit.Signature = string(sig)
+}
+
+// readTranscriptFromTree reads a transcript from a git tree, handling both chunked and non-chunked formats.
+// It checks for chunk files first (.001, .002, etc.), then falls back to the base file.
+// The agentType is used for reassembling chunks in the correct format.
+func readTranscriptFromTree(ctx context.Context, tree *FetchingTree, agentType types.AgentType) ([]byte, error) {
+ // Collect all transcript-related files
+ var chunkFiles []string
+ var hasBaseFile bool
+
+ for _, entry := range tree.RawEntries() {
+ if entry.Name == paths.TranscriptFileName || entry.Name == paths.TranscriptFileNameLegacy {
+ hasBaseFile = true
+ }
+ // Check for chunk files (full.jsonl.001, full.jsonl.002, etc.)
+ if strings.HasPrefix(entry.Name, paths.TranscriptFileName+".") {
+ idx := agent.ParseChunkIndex(entry.Name, paths.TranscriptFileName)
+ if idx > 0 {
+ chunkFiles = append(chunkFiles, entry.Name)
+ }
+ }
+ }
+
+ // If we have chunk files, read and reassemble them
+ if len(chunkFiles) > 0 {
+ // Sort chunk files by index
+ chunkFiles = agent.SortChunkFiles(chunkFiles, paths.TranscriptFileName)
+
+ // Check if base file should be included as chunk 0.
+ // NOTE: This assumes the chunking convention where the unsuffixed file
+ // (full.jsonl) is chunk 0, and numbered files (.001, .002) are chunks 1+.
+ if hasBaseFile {
+ chunkFiles = append([]string{paths.TranscriptFileName}, chunkFiles...)
+ }
+
+ var chunks [][]byte
+ for _, chunkFile := range chunkFiles {
+ file, err := tree.File(chunkFile)
+ if err != nil {
+ logging.Warn(
+ ctx, "failed to read transcript chunk file from tree",
+ slog.String("chunk_file", chunkFile),
+ slog.String("error", err.Error()),
+ )
+ continue
+ }
+ content, err := file.Contents()
+ if err != nil {
+ logging.Warn(
+ ctx, "failed to read transcript chunk contents",
+ slog.String("chunk_file", chunkFile),
+ slog.String("error", err.Error()),
+ )
+ continue
+ }
+ chunks = append(chunks, []byte(content))
+ }
+
+ if len(chunks) > 0 {
+ result, err := agent.ReassembleTranscript(chunks, agentType)
+ if err != nil {
+ return nil, fmt.Errorf("failed to reassemble transcript: %w", err)
+ }
+ return result, nil
+ }
+ }
+
+ // Fall back to reading base file (non-chunked or backwards compatibility)
+ if file, err := tree.File(paths.TranscriptFileName); err == nil {
+ if content, err := file.Contents(); err == nil {
+ return []byte(content), nil
+ }
+ }
+
+ // Try legacy filename
+ if file, err := tree.File(paths.TranscriptFileNameLegacy); err == nil {
+ if content, err := file.Contents(); err == nil {
+ return []byte(content), nil
+ }
+ }
+
+ return nil, nil
+}
+
+// Author contains author information for a checkpoint.
+type Author struct {
+ Name string
+ Email string
+}
+
+// GetCheckpointAuthor retrieves the author of a checkpoint from the trace/checkpoints/v1 commit history.
+// Finds the commit whose subject matches "Checkpoint: " and returns its author.
+// Returns empty Author if the checkpoint is not found or the sessions branch doesn't exist.
+func (s *GitStore) GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error) {
+ StorerMu.Lock()
+ defer StorerMu.Unlock()
+
+ if err := ctx.Err(); err != nil {
+ return Author{}, err //nolint:wrapcheck // Propagating context cancellation
+ }
+
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := s.repo.Reference(refName, true)
+ if err != nil {
+ return Author{}, nil
+ }
+
+ // Search for the commit whose subject matches "Checkpoint: "
+ targetSubject := "Checkpoint: " + checkpointID.String()
+
+ iter, err := s.repo.Log(&git.LogOptions{
+ From: ref.Hash(),
+ Order: git.LogOrderCommitterTime,
+ })
+ if err != nil {
+ return Author{}, nil
+ }
+ defer iter.Close()
+
+ var author Author
+ err = iter.ForEach(func(c *object.Commit) error {
+ if err := ctx.Err(); err != nil {
+ return err //nolint:wrapcheck // Propagating context cancellation
+ }
+ subject := strings.SplitN(c.Message, "\n", 2)[0]
+ if subject == targetSubject {
+ author = Author{
+ Name: c.Author.Name,
+ Email: c.Author.Email,
+ }
+ return errStopIteration
+ }
+ return nil
+ })
+
+ if err != nil && !errors.Is(err, errStopIteration) {
+ return Author{}, nil
+ }
+
+ return author, nil
+}
diff --git a/cli/checkpoint/temporary.go b/cli/checkpoint/temporary.go
index a47b53e..0c63445 100644
--- a/cli/checkpoint/temporary.go
+++ b/cli/checkpoint/temporary.go
@@ -10,8 +10,6 @@ import (
"log/slog"
"os"
"os/exec"
- "path/filepath"
- "sort"
"strings"
"time"
@@ -822,601 +820,3 @@ func (s *GitStore) getOrCreateShadowBranch(branchName string) (plumbing.Hash, pl
return plumbing.ZeroHash, headCommit.TreeHash, nil
}
-
-// buildTreeWithChanges builds a git tree with the given changes.
-// metadataDir is the relative path for git tree entries, metadataDirAbs is the absolute path
-// for filesystem operations (needed when CLI is run from a subdirectory).
-//
-// Uses ApplyTreeChanges (tree surgery) instead of FlattenTree+BuildTreeFromEntries,
-// so only affected subtrees are read/rebuilt — O(changed dirs) instead of O(total files).
-func (s *GitStore) buildTreeWithChanges(
- ctx context.Context,
- baseTreeHash plumbing.Hash,
- modifiedFiles, deletedFiles []string,
- metadataDir, metadataDirAbs string,
-) (plumbing.Hash, error) {
- // Get worktree root for resolving file paths
- // This is critical because fileExists() and createBlobFromFile() use os.Stat()
- // which resolves relative to CWD. The modifiedFiles are repo-relative paths,
- // so we must resolve them against repo root, not CWD.
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to get worktree root: %w", err)
- }
-
- // Build list of tree changes
- changes := make([]TreeChange, 0, len(modifiedFiles)+len(deletedFiles))
-
- // Deleted files → nil Entry means deletion
- for _, file := range deletedFiles {
- relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file)
- if relErr != nil {
- logInvalidGitTreePath(ctx, "delete shadow branch entry", file, relErr)
- continue
- }
- changes = append(changes, TreeChange{Path: relPath, Entry: nil})
- }
-
- // Modified/new files → create blobs from disk
- for _, file := range modifiedFiles {
- relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file)
- if relErr != nil {
- logInvalidGitTreePath(ctx, "add shadow branch entry", file, relErr)
- continue
- }
-
- absPath := filepath.Join(repoRoot, filepath.FromSlash(relPath))
- if !fileExists(absPath) {
- // File disappeared since detection — treat as deletion
- changes = append(changes, TreeChange{Path: relPath, Entry: nil})
- continue
- }
-
- blobHash, mode, blobErr := createBlobFromFile(s.repo, absPath)
- if blobErr != nil {
- // Skip files that can't be staged (may have been deleted since detection)
- continue
- }
-
- changes = append(changes, TreeChange{
- Path: relPath,
- Entry: &object.TreeEntry{
- Mode: mode,
- Hash: blobHash,
- },
- })
- }
-
- // Metadata directory files
- if metadataDir != "" && metadataDirAbs != "" {
- metadataRel, relErr := normalizeRepoRelativeTreePath(repoRoot, metadataDir)
- if relErr != nil {
- logInvalidGitTreePath(ctx, "add metadata directory", metadataDir, relErr)
- } else {
- metaChanges, metaErr := addDirectoryToChanges(s.repo, metadataDirAbs, metadataRel)
- if metaErr != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to add metadata directory: %w", metaErr)
- }
- changes = append(changes, metaChanges...)
- }
- }
-
- return ApplyTreeChanges(ctx, s.repo, baseTreeHash, changes)
-}
-
-// createCommit creates a commit object.
-func (s *GitStore) createCommit(ctx context.Context, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) {
- return CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail)
-}
-
-// Helper functions extracted from strategy/common.go
-// These are exported for use by strategy package (push_common.go, session_test.go)
-
-// FlattenTree recursively flattens a tree into a map of full paths to entries.
-func FlattenTree(repo *git.Repository, tree *object.Tree, prefix string, entries map[string]object.TreeEntry) error {
- for _, entry := range tree.Entries {
- fullPath := entry.Name
- if prefix != "" {
- fullPath = prefix + "/" + entry.Name
- }
-
- if entry.Mode == filemode.Dir {
- // Recurse into subtree
- subtree, err := repo.TreeObject(entry.Hash)
- if err != nil {
- return fmt.Errorf("failed to get subtree %s: %w", fullPath, err)
- }
- if err := FlattenTree(repo, subtree, fullPath, entries); err != nil {
- return err
- }
- } else {
- entries[fullPath] = object.TreeEntry{
- Name: fullPath,
- Mode: entry.Mode,
- Hash: entry.Hash,
- }
- }
- }
- return nil
-}
-
-// fileExists checks if a file exists at the given path.
-func fileExists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
-}
-
-// createBlobFromFile creates a blob object from a file in the working directory.
-func createBlobFromFile(repo *git.Repository, filePath string) (plumbing.Hash, filemode.FileMode, error) {
- info, err := os.Stat(filePath)
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to stat file: %w", err)
- }
-
- // Determine file mode
- mode := filemode.Regular
- if info.Mode()&0o111 != 0 {
- mode = filemode.Executable
- }
- if info.Mode()&os.ModeSymlink != 0 {
- mode = filemode.Symlink
- }
-
- // Read file contents
- content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the repository
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err)
- }
-
- // Create blob object
- obj := repo.Storer.NewEncodedObject()
- obj.SetType(plumbing.BlobObject)
- obj.SetSize(int64(len(content)))
-
- writer, err := obj.Writer()
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to get object writer: %w", err)
- }
-
- _, err = writer.Write(content)
- if err != nil {
- _ = writer.Close()
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to write blob content: %w", err)
- }
- if err := writer.Close(); err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to close blob writer: %w", err)
- }
-
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- return plumbing.ZeroHash, 0, fmt.Errorf("failed to store blob object: %w", err)
- }
-
- return hash, mode, nil
-}
-
-// addDirectoryToEntriesWithAbsPath recursively adds all files in a directory to the entries map.
-func addDirectoryToEntriesWithAbsPath(repo *git.Repository, dirPathAbs, dirPathRel string, entries map[string]object.TreeEntry) error {
- err := filepath.Walk(dirPathAbs, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- // Skip symlinks to prevent reading files outside the metadata directory.
- // A symlink could point to sensitive files (e.g., /etc/passwd) which would
- // then be captured in the checkpoint and stored in git history.
- // NOTE: filepath.Walk uses os.Stat (follows symlinks), so info.Mode() never
- // reports ModeSymlink. We use os.Lstat to check the entry itself.
- // This check MUST come before IsDir() because Walk follows symlinked
- // directories and would recurse into them otherwise.
- linfo, lstatErr := os.Lstat(path)
- if lstatErr != nil {
- return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
- }
- if linfo.Mode()&os.ModeSymlink != 0 {
- if info.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
-
- if info.IsDir() {
- return nil
- }
-
- // Calculate relative path within the directory, then join with dirPathRel for tree entry
- relWithinDir, err := filepath.Rel(dirPathAbs, path)
- if err != nil {
- return fmt.Errorf("failed to get relative path for %s: %w", path, err)
- }
-
- // Prevent path traversal via symlinks pointing outside the metadata dir
- if strings.HasPrefix(relWithinDir, "..") {
- return fmt.Errorf("path traversal detected: %s", relWithinDir)
- }
-
- treePath := filepath.ToSlash(filepath.Join(dirPathRel, relWithinDir))
-
- // Use redacted blob creation for metadata files (transcripts, prompts, etc.)
- // to ensure PII and secrets are redacted before writing to git.
- blobHash, mode, err := createRedactedBlobFromFile(repo, path, treePath)
- if err != nil {
- return fmt.Errorf("failed to create blob for %s: %w", path, err)
- }
- entries[treePath] = object.TreeEntry{
- Name: treePath,
- Mode: mode,
- Hash: blobHash,
- }
- return nil
- })
- if err != nil {
- return fmt.Errorf("failed to walk directory %s: %w", dirPathAbs, err)
- }
- return nil
-}
-
-// treeNode represents a node in our tree structure.
-type treeNode struct {
- entries map[string]*treeNode // subdirectories
- files []object.TreeEntry // files in this directory
-}
-
-// addDirectoryToChanges walks a filesystem directory and returns TreeChange entries
-// for each file, suitable for use with ApplyTreeChanges.
-// dirPathAbs is the absolute filesystem path; dirPathRel is the git tree-relative path.
-func addDirectoryToChanges(repo *git.Repository, dirPathAbs, dirPathRel string) ([]TreeChange, error) {
- var changes []TreeChange
- err := filepath.Walk(dirPathAbs, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- // Skip symlinks (same security rationale as addDirectoryToEntriesWithAbsPath)
- linfo, lstatErr := os.Lstat(path)
- if lstatErr != nil {
- return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
- }
- if linfo.Mode()&os.ModeSymlink != 0 {
- if info.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
-
- if info.IsDir() {
- return nil
- }
-
- relWithinDir, relErr := filepath.Rel(dirPathAbs, path)
- if relErr != nil {
- return fmt.Errorf("failed to get relative path for %s: %w", path, relErr)
- }
- if strings.HasPrefix(relWithinDir, "..") {
- return fmt.Errorf("path traversal detected: %s", relWithinDir)
- }
-
- treePath := filepath.ToSlash(filepath.Join(dirPathRel, relWithinDir))
-
- blobHash, mode, blobErr := createRedactedBlobFromFile(repo, path, treePath)
- if blobErr != nil {
- return fmt.Errorf("failed to create blob for %s: %w", path, blobErr)
- }
- changes = append(changes, TreeChange{
- Path: treePath,
- Entry: &object.TreeEntry{Mode: mode, Hash: blobHash},
- })
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("failed to walk directory %s: %w", dirPathAbs, err)
- }
- return changes, nil
-}
-
-// BuildTreeFromEntries builds a proper git tree structure from flattened file entries.
-// Exported for use by strategy package (push_common.go, session_test.go)
-func BuildTreeFromEntries(ctx context.Context, repo *git.Repository, entries map[string]object.TreeEntry) (plumbing.Hash, error) {
- // Build a tree structure
- root := &treeNode{
- entries: make(map[string]*treeNode),
- files: []object.TreeEntry{},
- }
-
- // Insert all entries into the tree structure
- for fullPath, entry := range entries {
- normalizedPath, err := normalizeGitTreePath(fullPath)
- if err != nil {
- logInvalidGitTreePath(ctx, "build tree entry", fullPath, err)
- continue
- }
- parts := strings.Split(normalizedPath, "/")
- insertIntoTree(root, parts, entry)
- }
-
- // Recursively build tree objects from bottom up
- return buildTreeObject(repo, root)
-}
-
-func normalizeRepoRelativeTreePath(repoRoot, path string) (string, error) {
- if rel := paths.ToRelativePath(path, repoRoot); rel != "" && rel != "." {
- return normalizeGitTreePath(rel)
- }
-
- return normalizeGitTreePath(path)
-}
-
-// insertIntoTree inserts a file entry into the tree structure.
-func insertIntoTree(node *treeNode, pathParts []string, entry object.TreeEntry) {
- if len(pathParts) == 1 {
- // This is a file in the current directory
- node.files = append(node.files, object.TreeEntry{
- Name: pathParts[0],
- Mode: entry.Mode,
- Hash: entry.Hash,
- })
- return
- }
-
- // This is in a subdirectory
- dirName := pathParts[0]
- if node.entries[dirName] == nil {
- node.entries[dirName] = &treeNode{
- entries: make(map[string]*treeNode),
- files: []object.TreeEntry{},
- }
- }
- insertIntoTree(node.entries[dirName], pathParts[1:], entry)
-}
-
-// buildTreeObject recursively builds tree objects from a treeNode.
-func buildTreeObject(repo *git.Repository, node *treeNode) (plumbing.Hash, error) {
- var treeEntries []object.TreeEntry
-
- // Add files
- treeEntries = append(treeEntries, node.files...)
-
- // Recursively build subtrees
- for name, subnode := range node.entries {
- subHash, err := buildTreeObject(repo, subnode)
- if err != nil {
- return plumbing.ZeroHash, err
- }
- treeEntries = append(treeEntries, object.TreeEntry{
- Name: name,
- Mode: filemode.Dir,
- Hash: subHash,
- })
- }
-
- // Sort entries (git requires sorted entries)
- sortTreeEntries(treeEntries)
-
- // Create tree object
- tree := &object.Tree{Entries: treeEntries}
-
- obj := repo.Storer.NewEncodedObject()
- if err := tree.Encode(obj); err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to encode tree: %w", err)
- }
-
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to store tree: %w", err)
- }
-
- return hash, nil
-}
-
-// sortTreeEntries sorts tree entries in git's required order.
-// Git sorts tree entries by name, with directories having a trailing /
-func sortTreeEntries(entries []object.TreeEntry) {
- sort.Slice(entries, func(i, j int) bool {
- nameI := entries[i].Name
- nameJ := entries[j].Name
- if entries[i].Mode == filemode.Dir {
- nameI += "/"
- }
- if entries[j].Mode == filemode.Dir {
- nameJ += "/"
- }
- return nameI < nameJ
- })
-}
-
-// collectChangedFiles collects all changed files (modified tracked + untracked non-ignored)
-// using git CLI. This is much faster than filesystem walk and respects all gitignore sources
-// including global gitignore (core.excludesfile).
-//
-// Uses git CLI instead of go-git because go-git's worktree.Status() does not respect
-// global gitignore, which can cause globally ignored files to appear as untracked.
-// See: https://github.com/GrayCodeAI/trace/pull/129
-//
-// changedFilesResult contains both changed and deleted files from git status.
-type changedFilesResult struct {
- Changed []string // Files to include (modified, added, untracked, renamed, etc.)
- Deleted []string // Files that were deleted (need to be excluded from checkpoint tree)
-}
-
-// filterGitIgnoredFiles removes gitignored files from the list using `git check-ignore`.
-// This prevents secrets in gitignored files (e.g., .env) from leaking into shadow branch
-// commits when agents report them as modified/new in their transcripts.
-// On failure, fails closed (returns nil) to avoid leaking secrets.
-func filterGitIgnoredFiles(ctx context.Context, repo *git.Repository, files []string) []string {
- if len(files) == 0 {
- return files
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- logging.Warn(logging.WithComponent(ctx, "checkpoint"),
- "failed to inspect worktree for gitignore filtering, excluding all files from checkpoint",
- slog.String("error", err.Error()))
- return nil
- }
- repoRoot := wt.Filesystem.Root()
-
- // Use git check-ignore to identify which files are ignored.
- // Pass files via stdin (-z for NUL-separated, --stdin) to handle special characters.
- // Use --no-index so even tracked files that still match ignore rules are filtered.
- cmd := exec.CommandContext(ctx, "git", "check-ignore", "--no-index", "-z", "--stdin")
- cmd.Dir = repoRoot
- cmd.Stdin = strings.NewReader(strings.Join(files, "\x00") + "\x00")
-
- output, err := cmd.Output()
- if err != nil {
- exitErr := &exec.ExitError{}
- if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
- // Exit code 1 means no files are ignored — all files are safe.
- return files
- }
- // Any other failure (exit 128, git not found, etc.): fail closed.
- // A missing checkpoint is better than leaked secrets.
- logging.Warn(logging.WithComponent(ctx, "checkpoint"),
- "git check-ignore failed, excluding all files from checkpoint",
- slog.String("error", err.Error()))
- return nil
- }
-
- // Parse NUL-separated output of ignored file names
- ignored := make(map[string]struct{})
- for _, name := range strings.Split(string(output), "\x00") {
- if name != "" {
- ignored[name] = struct{}{}
- }
- }
-
- // Filter: keep only files that are not ignored
- var kept []string
- filteredCount := 0
- for _, file := range files {
- if _, isIgnored := ignored[file]; isIgnored {
- filteredCount++
- continue
- }
- kept = append(kept, file)
- }
-
- if filteredCount > 0 {
- logging.Debug(logging.WithComponent(ctx, "checkpoint"),
- "filtered gitignored files from checkpoint",
- slog.Int("count", filteredCount))
- }
-
- return kept
-}
-
-// collectChangedFiles returns all changed files from git status for the first checkpoint.
-//
-// For the first checkpoint, we need to capture:
-// - Modified tracked files (user's uncommitted changes)
-// - Untracked non-ignored files (new files not yet added to git)
-// - Renamed/copied files (both source removal and destination)
-// - Deleted files (to exclude from checkpoint tree)
-//
-// The base tree from HEAD already contains all unchanged tracked files.
-//
-// Uses `git status --porcelain -z` for reliable parsing of filenames with special characters.
-func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFilesResult, error) {
- // Get worktree root directory for running git command
- wt, err := repo.Worktree()
- if err != nil {
- return changedFilesResult{}, fmt.Errorf("failed to get worktree: %w", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- // Use -z for NUL-separated output (handles quoted filenames with spaces/special chars)
- // Use -uall to list individual untracked files instead of collapsed directories.
- // Note: CLAUDE.md warns against -uall for user-facing display, but we need the full list
- // for checkpointing.
- cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "-z", "-uall")
- cmd.Dir = repoRoot
- output, err := cmd.Output()
- if err != nil {
- return changedFilesResult{}, fmt.Errorf("failed to get git status in %s: %w", repoRoot, err)
- }
-
- changedSeen := make(map[string]struct{})
- deletedSeen := make(map[string]struct{})
-
- // Parse NUL-separated output
- // Format: XY filename\0 (for most entries)
- // For renames/copies: XY newname\0oldname\0
- entries := strings.Split(string(output), "\x00")
-
- for i := 0; i < len(entries); i++ {
- entry := entries[i]
- if len(entry) < 3 {
- continue
- }
-
- // git status --porcelain format: XY filename
- // X = staging status, Y = worktree status
- staging := entry[0]
- wtStatus := entry[1]
- filename := entry[3:] // No TrimSpace needed with -z format
-
- // Handle R/C (rename/copy) first - they have a second entry we must skip
- // even if the new filename is an infrastructure path
- if staging == 'R' || staging == 'C' {
- // Renamed or copied: current entry is new name, next entry is old name
- if !paths.IsInfrastructurePath(filename) {
- changedSeen[filename] = struct{}{}
- }
- // The old name follows as the next NUL-separated entry - must always skip it
- if i+1 < len(entries) && entries[i+1] != "" {
- oldName := entries[i+1]
- if staging == 'R' && !paths.IsInfrastructurePath(oldName) {
- // For renames, old file is effectively deleted
- deletedSeen[oldName] = struct{}{}
- }
- i++ // Skip the old name entry
- }
- continue
- }
-
- // Skip .trace directory for non-R/C entries
- if paths.IsInfrastructurePath(filename) {
- continue
- }
-
- // Handle different status codes
- switch {
- case staging == 'D' || wtStatus == 'D':
- // Deleted file - track separately
- deletedSeen[filename] = struct{}{}
-
- case wtStatus == 'M' || wtStatus == 'A':
- // Modified or added in worktree
- changedSeen[filename] = struct{}{}
-
- case staging == '?' && wtStatus == '?':
- // Untracked file
- changedSeen[filename] = struct{}{}
-
- case staging == 'A' || staging == 'M':
- // Staged add or modify
- changedSeen[filename] = struct{}{}
-
- case staging == 'T' || wtStatus == 'T':
- // Type change (e.g., file to symlink)
- changedSeen[filename] = struct{}{}
-
- case staging == 'U' || wtStatus == 'U':
- // Unmerged (conflict) - include current file state
- changedSeen[filename] = struct{}{}
- }
- }
-
- changed := make([]string, 0, len(changedSeen))
- for file := range changedSeen {
- changed = append(changed, file)
- }
-
- deleted := make([]string, 0, len(deletedSeen))
- for file := range deletedSeen {
- deleted = append(deleted, file)
- }
-
- return changedFilesResult{Changed: changed, Deleted: deleted}, nil
-}
diff --git a/cli/checkpoint/temporary_2.go b/cli/checkpoint/temporary_2.go
new file mode 100644
index 0000000..ad72b88
--- /dev/null
+++ b/cli/checkpoint/temporary_2.go
@@ -0,0 +1,619 @@
+package checkpoint
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// buildTreeWithChanges builds a git tree with the given changes.
+// metadataDir is the relative path for git tree entries, metadataDirAbs is the absolute path
+// for filesystem operations (needed when CLI is run from a subdirectory).
+//
+// Uses ApplyTreeChanges (tree surgery) instead of FlattenTree+BuildTreeFromEntries,
+// so only affected subtrees are read/rebuilt — O(changed dirs) instead of O(total files).
+func (s *GitStore) buildTreeWithChanges(
+ ctx context.Context,
+ baseTreeHash plumbing.Hash,
+ modifiedFiles, deletedFiles []string,
+ metadataDir, metadataDirAbs string,
+) (plumbing.Hash, error) {
+ // Get worktree root for resolving file paths
+ // This is critical because fileExists() and createBlobFromFile() use os.Stat()
+ // which resolves relative to CWD. The modifiedFiles are repo-relative paths,
+ // so we must resolve them against repo root, not CWD.
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to get worktree root: %w", err)
+ }
+
+ // Build list of tree changes
+ changes := make([]TreeChange, 0, len(modifiedFiles)+len(deletedFiles))
+
+ // Deleted files → nil Entry means deletion
+ for _, file := range deletedFiles {
+ relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file)
+ if relErr != nil {
+ logInvalidGitTreePath(ctx, "delete shadow branch entry", file, relErr)
+ continue
+ }
+ changes = append(changes, TreeChange{Path: relPath, Entry: nil})
+ }
+
+ // Modified/new files → create blobs from disk
+ for _, file := range modifiedFiles {
+ relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file)
+ if relErr != nil {
+ logInvalidGitTreePath(ctx, "add shadow branch entry", file, relErr)
+ continue
+ }
+
+ absPath := filepath.Join(repoRoot, filepath.FromSlash(relPath))
+ if !fileExists(absPath) {
+ // File disappeared since detection — treat as deletion
+ changes = append(changes, TreeChange{Path: relPath, Entry: nil})
+ continue
+ }
+
+ blobHash, mode, blobErr := createBlobFromFile(s.repo, absPath)
+ if blobErr != nil {
+ // Skip files that can't be staged (may have been deleted since detection)
+ continue
+ }
+
+ changes = append(changes, TreeChange{
+ Path: relPath,
+ Entry: &object.TreeEntry{
+ Mode: mode,
+ Hash: blobHash,
+ },
+ })
+ }
+
+ // Metadata directory files
+ if metadataDir != "" && metadataDirAbs != "" {
+ metadataRel, relErr := normalizeRepoRelativeTreePath(repoRoot, metadataDir)
+ if relErr != nil {
+ logInvalidGitTreePath(ctx, "add metadata directory", metadataDir, relErr)
+ } else {
+ metaChanges, metaErr := addDirectoryToChanges(s.repo, metadataDirAbs, metadataRel)
+ if metaErr != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to add metadata directory: %w", metaErr)
+ }
+ changes = append(changes, metaChanges...)
+ }
+ }
+
+ return ApplyTreeChanges(ctx, s.repo, baseTreeHash, changes)
+}
+
+// createCommit creates a commit object.
+func (s *GitStore) createCommit(ctx context.Context, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) {
+ return CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail)
+}
+
+// Helper functions extracted from strategy/common.go
+// These are exported for use by strategy package (push_common.go, session_test.go)
+
+// FlattenTree recursively flattens a tree into a map of full paths to entries.
+func FlattenTree(repo *git.Repository, tree *object.Tree, prefix string, entries map[string]object.TreeEntry) error {
+ for _, entry := range tree.Entries {
+ fullPath := entry.Name
+ if prefix != "" {
+ fullPath = prefix + "/" + entry.Name
+ }
+
+ if entry.Mode == filemode.Dir {
+ // Recurse into subtree
+ subtree, err := repo.TreeObject(entry.Hash)
+ if err != nil {
+ return fmt.Errorf("failed to get subtree %s: %w", fullPath, err)
+ }
+ if err := FlattenTree(repo, subtree, fullPath, entries); err != nil {
+ return err
+ }
+ } else {
+ entries[fullPath] = object.TreeEntry{
+ Name: fullPath,
+ Mode: entry.Mode,
+ Hash: entry.Hash,
+ }
+ }
+ }
+ return nil
+}
+
+// fileExists checks if a file exists at the given path.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// createBlobFromFile creates a blob object from a file in the working directory.
+func createBlobFromFile(repo *git.Repository, filePath string) (plumbing.Hash, filemode.FileMode, error) {
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to stat file: %w", err)
+ }
+
+ // Determine file mode
+ mode := filemode.Regular
+ if info.Mode()&0o111 != 0 {
+ mode = filemode.Executable
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ mode = filemode.Symlink
+ }
+
+ // Read file contents
+ content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the repository
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err)
+ }
+
+ // Create blob object
+ obj := repo.Storer.NewEncodedObject()
+ obj.SetType(plumbing.BlobObject)
+ obj.SetSize(int64(len(content)))
+
+ writer, err := obj.Writer()
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to get object writer: %w", err)
+ }
+
+ _, err = writer.Write(content)
+ if err != nil {
+ _ = writer.Close()
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to write blob content: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to close blob writer: %w", err)
+ }
+
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ return plumbing.ZeroHash, 0, fmt.Errorf("failed to store blob object: %w", err)
+ }
+
+ return hash, mode, nil
+}
+
+// addDirectoryToEntriesWithAbsPath recursively adds all files in a directory to the entries map.
+func addDirectoryToEntriesWithAbsPath(repo *git.Repository, dirPathAbs, dirPathRel string, entries map[string]object.TreeEntry) error {
+ err := filepath.Walk(dirPathAbs, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip symlinks to prevent reading files outside the metadata directory.
+ // A symlink could point to sensitive files (e.g., /etc/passwd) which would
+ // then be captured in the checkpoint and stored in git history.
+ // NOTE: filepath.Walk uses os.Stat (follows symlinks), so info.Mode() never
+ // reports ModeSymlink. We use os.Lstat to check the entry itself.
+ // This check MUST come before IsDir() because Walk follows symlinked
+ // directories and would recurse into them otherwise.
+ linfo, lstatErr := os.Lstat(path)
+ if lstatErr != nil {
+ return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
+ }
+ if linfo.Mode()&os.ModeSymlink != 0 {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ if info.IsDir() {
+ return nil
+ }
+
+ // Calculate relative path within the directory, then join with dirPathRel for tree entry
+ relWithinDir, err := filepath.Rel(dirPathAbs, path)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path for %s: %w", path, err)
+ }
+
+ // Prevent path traversal via symlinks pointing outside the metadata dir
+ if strings.HasPrefix(relWithinDir, "..") {
+ return fmt.Errorf("path traversal detected: %s", relWithinDir)
+ }
+
+ treePath := filepath.ToSlash(filepath.Join(dirPathRel, relWithinDir))
+
+ // Use redacted blob creation for metadata files (transcripts, prompts, etc.)
+ // to ensure PII and secrets are redacted before writing to git.
+ blobHash, mode, err := createRedactedBlobFromFile(repo, path, treePath)
+ if err != nil {
+ return fmt.Errorf("failed to create blob for %s: %w", path, err)
+ }
+ entries[treePath] = object.TreeEntry{
+ Name: treePath,
+ Mode: mode,
+ Hash: blobHash,
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to walk directory %s: %w", dirPathAbs, err)
+ }
+ return nil
+}
+
+// treeNode represents a node in our tree structure.
+type treeNode struct {
+ entries map[string]*treeNode // subdirectories
+ files []object.TreeEntry // files in this directory
+}
+
+// addDirectoryToChanges walks a filesystem directory and returns TreeChange entries
+// for each file, suitable for use with ApplyTreeChanges.
+// dirPathAbs is the absolute filesystem path; dirPathRel is the git tree-relative path.
+func addDirectoryToChanges(repo *git.Repository, dirPathAbs, dirPathRel string) ([]TreeChange, error) {
+ var changes []TreeChange
+ err := filepath.Walk(dirPathAbs, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip symlinks (same security rationale as addDirectoryToEntriesWithAbsPath)
+ linfo, lstatErr := os.Lstat(path)
+ if lstatErr != nil {
+ return fmt.Errorf("failed to lstat %s: %w", path, lstatErr)
+ }
+ if linfo.Mode()&os.ModeSymlink != 0 {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ if info.IsDir() {
+ return nil
+ }
+
+ relWithinDir, relErr := filepath.Rel(dirPathAbs, path)
+ if relErr != nil {
+ return fmt.Errorf("failed to get relative path for %s: %w", path, relErr)
+ }
+ if strings.HasPrefix(relWithinDir, "..") {
+ return fmt.Errorf("path traversal detected: %s", relWithinDir)
+ }
+
+ treePath := filepath.ToSlash(filepath.Join(dirPathRel, relWithinDir))
+
+ blobHash, mode, blobErr := createRedactedBlobFromFile(repo, path, treePath)
+ if blobErr != nil {
+ return fmt.Errorf("failed to create blob for %s: %w", path, blobErr)
+ }
+ changes = append(changes, TreeChange{
+ Path: treePath,
+ Entry: &object.TreeEntry{Mode: mode, Hash: blobHash},
+ })
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to walk directory %s: %w", dirPathAbs, err)
+ }
+ return changes, nil
+}
+
+// BuildTreeFromEntries builds a proper git tree structure from flattened file entries.
+// Exported for use by strategy package (push_common.go, session_test.go)
+func BuildTreeFromEntries(ctx context.Context, repo *git.Repository, entries map[string]object.TreeEntry) (plumbing.Hash, error) {
+ // Build a tree structure
+ root := &treeNode{
+ entries: make(map[string]*treeNode),
+ files: []object.TreeEntry{},
+ }
+
+ // Insert all entries into the tree structure
+ for fullPath, entry := range entries {
+ normalizedPath, err := normalizeGitTreePath(fullPath)
+ if err != nil {
+ logInvalidGitTreePath(ctx, "build tree entry", fullPath, err)
+ continue
+ }
+ parts := strings.Split(normalizedPath, "/")
+ insertIntoTree(root, parts, entry)
+ }
+
+ // Recursively build tree objects from bottom up
+ return buildTreeObject(repo, root)
+}
+
+func normalizeRepoRelativeTreePath(repoRoot, path string) (string, error) {
+ if rel := paths.ToRelativePath(path, repoRoot); rel != "" && rel != "." {
+ return normalizeGitTreePath(rel)
+ }
+
+ return normalizeGitTreePath(path)
+}
+
+// insertIntoTree inserts a file entry into the tree structure.
+func insertIntoTree(node *treeNode, pathParts []string, entry object.TreeEntry) {
+ if len(pathParts) == 1 {
+ // This is a file in the current directory
+ node.files = append(node.files, object.TreeEntry{
+ Name: pathParts[0],
+ Mode: entry.Mode,
+ Hash: entry.Hash,
+ })
+ return
+ }
+
+ // This is in a subdirectory
+ dirName := pathParts[0]
+ if node.entries[dirName] == nil {
+ node.entries[dirName] = &treeNode{
+ entries: make(map[string]*treeNode),
+ files: []object.TreeEntry{},
+ }
+ }
+ insertIntoTree(node.entries[dirName], pathParts[1:], entry)
+}
+
+// buildTreeObject recursively builds tree objects from a treeNode.
+func buildTreeObject(repo *git.Repository, node *treeNode) (plumbing.Hash, error) {
+ var treeEntries []object.TreeEntry
+
+ // Add files
+ treeEntries = append(treeEntries, node.files...)
+
+ // Recursively build subtrees
+ for name, subnode := range node.entries {
+ subHash, err := buildTreeObject(repo, subnode)
+ if err != nil {
+ return plumbing.ZeroHash, err
+ }
+ treeEntries = append(treeEntries, object.TreeEntry{
+ Name: name,
+ Mode: filemode.Dir,
+ Hash: subHash,
+ })
+ }
+
+ // Sort entries (git requires sorted entries)
+ sortTreeEntries(treeEntries)
+
+ // Create tree object
+ tree := &object.Tree{Entries: treeEntries}
+
+ obj := repo.Storer.NewEncodedObject()
+ if err := tree.Encode(obj); err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to encode tree: %w", err)
+ }
+
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to store tree: %w", err)
+ }
+
+ return hash, nil
+}
+
+// sortTreeEntries sorts tree entries in git's required order.
+// Git sorts tree entries by name, with directories having a trailing /
+func sortTreeEntries(entries []object.TreeEntry) {
+ sort.Slice(entries, func(i, j int) bool {
+ nameI := entries[i].Name
+ nameJ := entries[j].Name
+ if entries[i].Mode == filemode.Dir {
+ nameI += "/"
+ }
+ if entries[j].Mode == filemode.Dir {
+ nameJ += "/"
+ }
+ return nameI < nameJ
+ })
+}
+
+// collectChangedFiles collects all changed files (modified tracked + untracked non-ignored)
+// using git CLI. This is much faster than filesystem walk and respects all gitignore sources
+// including global gitignore (core.excludesfile).
+//
+// Uses git CLI instead of go-git because go-git's worktree.Status() does not respect
+// global gitignore, which can cause globally ignored files to appear as untracked.
+// See: https://github.com/GrayCodeAI/trace/pull/129
+//
+// changedFilesResult contains both changed and deleted files from git status.
+type changedFilesResult struct {
+ Changed []string // Files to include (modified, added, untracked, renamed, etc.)
+ Deleted []string // Files that were deleted (need to be excluded from checkpoint tree)
+}
+
+// filterGitIgnoredFiles removes gitignored files from the list using `git check-ignore`.
+// This prevents secrets in gitignored files (e.g., .env) from leaking into shadow branch
+// commits when agents report them as modified/new in their transcripts.
+// On failure, fails closed (returns nil) to avoid leaking secrets.
+func filterGitIgnoredFiles(ctx context.Context, repo *git.Repository, files []string) []string {
+ if len(files) == 0 {
+ return files
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ logging.Warn(logging.WithComponent(ctx, "checkpoint"),
+ "failed to inspect worktree for gitignore filtering, excluding all files from checkpoint",
+ slog.String("error", err.Error()))
+ return nil
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ // Use git check-ignore to identify which files are ignored.
+ // Pass files via stdin (-z for NUL-separated, --stdin) to handle special characters.
+ // Use --no-index so even tracked files that still match ignore rules are filtered.
+ cmd := exec.CommandContext(ctx, "git", "check-ignore", "--no-index", "-z", "--stdin")
+ cmd.Dir = repoRoot
+ cmd.Stdin = strings.NewReader(strings.Join(files, "\x00") + "\x00")
+
+ output, err := cmd.Output()
+ if err != nil {
+ exitErr := &exec.ExitError{}
+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
+ // Exit code 1 means no files are ignored — all files are safe.
+ return files
+ }
+ // Any other failure (exit 128, git not found, etc.): fail closed.
+ // A missing checkpoint is better than leaked secrets.
+ logging.Warn(logging.WithComponent(ctx, "checkpoint"),
+ "git check-ignore failed, excluding all files from checkpoint",
+ slog.String("error", err.Error()))
+ return nil
+ }
+
+ // Parse NUL-separated output of ignored file names
+ ignored := make(map[string]struct{})
+ for _, name := range strings.Split(string(output), "\x00") {
+ if name != "" {
+ ignored[name] = struct{}{}
+ }
+ }
+
+ // Filter: keep only files that are not ignored
+ var kept []string
+ filteredCount := 0
+ for _, file := range files {
+ if _, isIgnored := ignored[file]; isIgnored {
+ filteredCount++
+ continue
+ }
+ kept = append(kept, file)
+ }
+
+ if filteredCount > 0 {
+ logging.Debug(logging.WithComponent(ctx, "checkpoint"),
+ "filtered gitignored files from checkpoint",
+ slog.Int("count", filteredCount))
+ }
+
+ return kept
+}
+
+// collectChangedFiles returns all changed files from git status for the first checkpoint.
+//
+// For the first checkpoint, we need to capture:
+// - Modified tracked files (user's uncommitted changes)
+// - Untracked non-ignored files (new files not yet added to git)
+// - Renamed/copied files (both source removal and destination)
+// - Deleted files (to exclude from checkpoint tree)
+//
+// The base tree from HEAD already contains all unchanged tracked files.
+//
+// Uses `git status --porcelain -z` for reliable parsing of filenames with special characters.
+func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFilesResult, error) {
+ // Get worktree root directory for running git command
+ wt, err := repo.Worktree()
+ if err != nil {
+ return changedFilesResult{}, fmt.Errorf("failed to get worktree: %w", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ // Use -z for NUL-separated output (handles quoted filenames with spaces/special chars)
+ // Use -uall to list individual untracked files instead of collapsed directories.
+ // Note: CLAUDE.md warns against -uall for user-facing display, but we need the full list
+ // for checkpointing.
+ cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "-z", "-uall")
+ cmd.Dir = repoRoot
+ output, err := cmd.Output()
+ if err != nil {
+ return changedFilesResult{}, fmt.Errorf("failed to get git status in %s: %w", repoRoot, err)
+ }
+
+ changedSeen := make(map[string]struct{})
+ deletedSeen := make(map[string]struct{})
+
+ // Parse NUL-separated output
+ // Format: XY filename\0 (for most entries)
+ // For renames/copies: XY newname\0oldname\0
+ entries := strings.Split(string(output), "\x00")
+
+ for i := 0; i < len(entries); i++ {
+ entry := entries[i]
+ if len(entry) < 3 {
+ continue
+ }
+
+ // git status --porcelain format: XY filename
+ // X = staging status, Y = worktree status
+ staging := entry[0]
+ wtStatus := entry[1]
+ filename := entry[3:] // No TrimSpace needed with -z format
+
+ // Handle R/C (rename/copy) first - they have a second entry we must skip
+ // even if the new filename is an infrastructure path
+ if staging == 'R' || staging == 'C' {
+ // Renamed or copied: current entry is new name, next entry is old name
+ if !paths.IsInfrastructurePath(filename) {
+ changedSeen[filename] = struct{}{}
+ }
+ // The old name follows as the next NUL-separated entry - must always skip it
+ if i+1 < len(entries) && entries[i+1] != "" {
+ oldName := entries[i+1]
+ if staging == 'R' && !paths.IsInfrastructurePath(oldName) {
+ // For renames, old file is effectively deleted
+ deletedSeen[oldName] = struct{}{}
+ }
+ i++ // Skip the old name entry
+ }
+ continue
+ }
+
+ // Skip .trace directory for non-R/C entries
+ if paths.IsInfrastructurePath(filename) {
+ continue
+ }
+
+ // Handle different status codes
+ switch {
+ case staging == 'D' || wtStatus == 'D':
+ // Deleted file - track separately
+ deletedSeen[filename] = struct{}{}
+
+ case wtStatus == 'M' || wtStatus == 'A':
+ // Modified or added in worktree
+ changedSeen[filename] = struct{}{}
+
+ case staging == '?' && wtStatus == '?':
+ // Untracked file
+ changedSeen[filename] = struct{}{}
+
+ case staging == 'A' || staging == 'M':
+ // Staged add or modify
+ changedSeen[filename] = struct{}{}
+
+ case staging == 'T' || wtStatus == 'T':
+ // Type change (e.g., file to symlink)
+ changedSeen[filename] = struct{}{}
+
+ case staging == 'U' || wtStatus == 'U':
+ // Unmerged (conflict) - include current file state
+ changedSeen[filename] = struct{}{}
+ }
+ }
+
+ changed := make([]string, 0, len(changedSeen))
+ for file := range changedSeen {
+ changed = append(changed, file)
+ }
+
+ deleted := make([]string, 0, len(deletedSeen))
+ for file := range deletedSeen {
+ deleted = append(deleted, file)
+ }
+
+ return changedFilesResult{Changed: changed, Deleted: deleted}, nil
+}
diff --git a/cli/checkpoint/v2_store_2_test.go b/cli/checkpoint/v2_store_2_test.go
new file mode 100644
index 0000000..98d4b29
--- /dev/null
+++ b/cli/checkpoint/v2_store_2_test.go
@@ -0,0 +1,339 @@
+package checkpoint
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func TestV2GitStore_UpdateCommitted_CheckpointNotFound(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("bb44cc55dd66")
+
+ // Update without prior write should return error
+ err := store.UpdateCommitted(ctx, UpdateCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "nonexistent",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"hello"}`)),
+ Agent: agent.AgentTypeClaudeCode,
+ })
+ require.Error(t, err)
+}
+
+func TestV2GitStore_UpdateCommitted_PreservesExistingTaskMetadataInFullCurrent(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("cc55dd66ee77")
+
+ // Initial write creates checkpoint/session on both /main and /full/current.
+ err := store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session-task-preserve",
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"initial"}`)),
+ Prompts: []string{"first prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Inject task metadata into /full/current to emulate condensation-time task copy.
+ refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
+ parentHash, rootTreeHash, err := store.GetRefState(refName)
+ require.NoError(t, err)
+
+ taskPath := []string{string(cpID[:2]), string(cpID[2:]), "0", "tasks", "toolu_01TASK"}
+ checkpointJSON := []byte(`{"session_id":"test-session-task-preserve","tool_use_id":"toolu_01TASK"}`)
+ blobHash, err := CreateBlobFromContent(repo, checkpointJSON)
+ require.NoError(t, err)
+
+ newRootHash, err := UpdateSubtree(
+ repo, rootTreeHash,
+ taskPath,
+ []object.TreeEntry{{Name: "checkpoint.json", Mode: filemode.Regular, Hash: blobHash}},
+ UpdateSubtreeOptions{MergeMode: MergeKeepExisting},
+ )
+ require.NoError(t, err)
+
+ authorName, authorEmail := GetGitAuthorFromRepo(repo)
+ commitHash, err := CreateCommit(ctx, repo, newRootHash, parentHash,
+ fmt.Sprintf("Checkpoint: %s (task metadata)\n", cpID), authorName, authorEmail)
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
+
+ // Finalize checkpoint with full transcript (the stop-time path).
+ err = store.UpdateCommitted(ctx, UpdateCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session-task-preserve",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"finalized"}`)),
+ Prompts: []string{"first prompt", "second prompt"},
+ Agent: agent.AgentTypeClaudeCode,
+ })
+ require.NoError(t, err)
+
+ // Task metadata should still exist after UpdateCommitted.
+ fullTree := v2FullTree(t, repo)
+ _, err = fullTree.File(cpID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json")
+ require.NoError(t, err, "task metadata should be preserved on /full/current during UpdateCommitted")
+}
+
+func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ store.maxCheckpointsPerGeneration = 3 // Low threshold for testing
+ ctx := context.Background()
+
+ // Write 3 checkpoints — the 3rd should trigger rotation
+ for i := range 3 {
+ cpID := id.MustCheckpointID(fmt.Sprintf("%012x", i+1))
+ err := store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: fmt.Sprintf("session-rot-%d", i),
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"cp":%d}`, i))),
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+ }
+
+ // Verify an archived generation exists
+ archived, err := store.ListArchivedGenerations()
+ require.NoError(t, err)
+ assert.Len(t, archived, 1, "one archived generation should exist after rotation")
+
+ // Verify /full/current is now a fresh generation (empty tree, no generation.json)
+ _, freshTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+ freshCount, err := store.CountCheckpointsInTree(freshTreeHash)
+ require.NoError(t, err)
+ assert.Equal(t, 0, freshCount, "fresh /full/current should have no checkpoints")
+
+ // Verify the archived generation has 3 checkpoints
+ _, archiveTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullRefPrefix + archived[0]))
+ require.NoError(t, err)
+ archiveCount, err := store.CountCheckpointsInTree(archiveTreeHash)
+ require.NoError(t, err)
+ assert.Equal(t, 3, archiveCount)
+
+ // Write a 4th checkpoint — should land on the fresh /full/current
+ cpID4 := id.MustCheckpointID("000000000004")
+ err = store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID4,
+ SessionID: "session-rot-3",
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte(`{"cp":3}`)),
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ _, newTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+ newCount, err := store.CountCheckpointsInTree(newTreeHash)
+ require.NoError(t, err)
+ assert.Equal(t, 1, newCount, "new checkpoint should be on fresh generation")
+}
+
+func TestWriteCommitted_NoRotationBelowThreshold(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ store.maxCheckpointsPerGeneration = 5
+ ctx := context.Background()
+
+ // Write 3 checkpoints (below threshold of 5)
+ for i := range 3 {
+ cpID := id.MustCheckpointID(fmt.Sprintf("%012x", i+100))
+ err := store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: fmt.Sprintf("session-norot-%d", i),
+ Strategy: "manual-commit",
+ Agent: agent.AgentTypeClaudeCode,
+ Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"cp":%d}`, i))),
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+ }
+
+ // No rotation should have occurred
+ archived, err := store.ListArchivedGenerations()
+ require.NoError(t, err)
+ assert.Empty(t, archived, "no archived generations should exist below threshold")
+
+ _, noRotTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+ noRotCount, err := store.CountCheckpointsInTree(noRotTreeHash)
+ require.NoError(t, err)
+ assert.Equal(t, 3, noRotCount)
+}
+
+// TestV2GitStore_CleanupV1TranscriptFiles verifies that CleanupV1TranscriptFiles
+// removes legacy v1-named files (full.jsonl, full.jsonl.*, content_hash.txt)
+// from /full/current while preserving v2-named files.
+func TestV2GitStore_CleanupV1TranscriptFiles(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("851fcec4a874")
+
+ // Write initial checkpoint (sets up both /main and /full/current with v2 naming).
+ err := store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session-v1-cleanup",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":"initial"}` + "\n")),
+ Agent: agent.AgentTypeClaudeCode,
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Inject v1-named files (full.jsonl, full.jsonl.001, content_hash.txt)
+ // directly into the /full/current tree to simulate legacy data.
+ refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
+ parentHash, rootTreeHash, err := store.GetRefState(refName)
+ require.NoError(t, err)
+
+ basePath := cpID.Path() + "/"
+ sessionPath := basePath + "0/"
+
+ entries, err := store.gs.flattenCheckpointEntries(rootTreeHash, cpID.Path())
+ require.NoError(t, err)
+
+ v1Blob, err := CreateBlobFromContent(repo, []byte(`{"type":"human","message":"v1 data"}`+"\n"))
+ require.NoError(t, err)
+ v1HashBlob, err := CreateBlobFromContent(repo, []byte("sha256:v1hash"))
+ require.NoError(t, err)
+ v1ChunkBlob, err := CreateBlobFromContent(repo, []byte(`{"type":"assistant","message":"v1 chunk"}`+"\n"))
+ require.NoError(t, err)
+
+ entries[sessionPath+paths.TranscriptFileName] = object.TreeEntry{
+ Name: sessionPath + paths.TranscriptFileName,
+ Mode: filemode.Regular,
+ Hash: v1Blob,
+ }
+ entries[sessionPath+paths.TranscriptFileName+".001"] = object.TreeEntry{
+ Name: sessionPath + paths.TranscriptFileName + ".001",
+ Mode: filemode.Regular,
+ Hash: v1ChunkBlob,
+ }
+ entries[sessionPath+paths.ContentHashFileName] = object.TreeEntry{
+ Name: sessionPath + paths.ContentHashFileName,
+ Mode: filemode.Regular,
+ Hash: v1HashBlob,
+ }
+
+ newTreeHash, err := store.gs.spliceCheckpointSubtree(ctx, rootTreeHash, cpID, basePath, entries)
+ require.NoError(t, err)
+ err = store.updateRef(ctx, refName, newTreeHash, parentHash, "Inject v1 files", "Test", "test@test.com")
+ require.NoError(t, err)
+
+ // Verify v1-named files exist before cleanup.
+ tree := v2FullTree(t, repo)
+ cpPath := cpID.Path()
+ sessionTree, err := tree.Tree(cpPath + "/0")
+ require.NoError(t, err)
+ preCleanup := make(map[string]bool)
+ for _, entry := range sessionTree.Entries {
+ preCleanup[entry.Name] = true
+ }
+ assert.True(t, preCleanup[paths.TranscriptFileName], "full.jsonl should exist before cleanup")
+ assert.True(t, preCleanup[paths.TranscriptFileName+".001"], "full.jsonl.001 should exist before cleanup")
+ assert.True(t, preCleanup[paths.ContentHashFileName], "content_hash.txt should exist before cleanup")
+ assert.True(t, preCleanup[paths.V2RawTranscriptFileName], "raw_transcript should exist before cleanup")
+
+ // Run cleanup.
+ err = store.CleanupV1TranscriptFiles(ctx, cpID, 1)
+ require.NoError(t, err)
+
+ // Verify v1-named files are gone, v2-named files are preserved.
+ tree = v2FullTree(t, repo)
+ sessionTree, err = tree.Tree(cpPath + "/0")
+ require.NoError(t, err)
+
+ postCleanup := make(map[string]bool)
+ for _, entry := range sessionTree.Entries {
+ postCleanup[entry.Name] = true
+ }
+
+ assert.True(t, postCleanup[paths.V2RawTranscriptFileName], "raw_transcript should exist after cleanup")
+ assert.True(t, postCleanup[paths.V2RawTranscriptHashFileName], "raw_transcript_hash.txt should exist after cleanup")
+ assert.False(t, postCleanup[paths.TranscriptFileName], "full.jsonl should be removed after cleanup")
+ assert.False(t, postCleanup[paths.TranscriptFileName+".001"], "full.jsonl.001 should be removed after cleanup")
+ assert.False(t, postCleanup[paths.ContentHashFileName], "content_hash.txt should be removed after cleanup")
+}
+
+// TestV2GitStore_CleanupV1TranscriptFiles_NoopWhenClean verifies that
+// CleanupV1TranscriptFiles is a no-op when no v1 files exist.
+func TestV2GitStore_CleanupV1TranscriptFiles_NoopWhenClean(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("962fcec4a874")
+
+ err := store.WriteCommitted(ctx, WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session-noop",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":"clean"}` + "\n")),
+ Agent: agent.AgentTypeClaudeCode,
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Get tree hash before cleanup.
+ _, treeBefore, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+
+ // Cleanup should be a no-op (no v1 files to remove).
+ err = store.CleanupV1TranscriptFiles(ctx, cpID, 1)
+ require.NoError(t, err)
+
+ // Tree hash should be unchanged (no commit created).
+ _, treeAfter, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+ assert.Equal(t, treeBefore, treeAfter, "tree should be unchanged when no v1 files exist")
+}
+
+func TestV2GitStore_CleanupV1TranscriptFiles_ReturnsCorruptRefError(t *testing.T) {
+ t.Parallel()
+ repo := initTestRepo(t)
+ store := NewV2GitStore(repo, "origin")
+
+ refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
+ missingCommit := plumbing.NewHash("1111111111111111111111111111111111111111")
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, missingCommit)))
+
+ err := store.CleanupV1TranscriptFiles(context.Background(), id.MustCheckpointID("962fcec4a874"), 1)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to get commit")
+}
diff --git a/cli/checkpoint/v2_store_test.go b/cli/checkpoint/v2_store_test.go
index 6d12c2e..097e4cf 100644
--- a/cli/checkpoint/v2_store_test.go
+++ b/cli/checkpoint/v2_store_test.go
@@ -3,7 +3,6 @@ package checkpoint
import (
"context"
"encoding/json"
- "fmt"
"strings"
"testing"
@@ -16,7 +15,6 @@ import (
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
- "github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
)
@@ -811,324 +809,3 @@ func TestV2GitStore_UpdateCommitted_NoTranscript_OnlyUpdatesMain(t *testing.T) {
content := v2ReadFile(t, fullTree, cpID.Path()+"/0/"+paths.V2RawTranscriptFileName)
assert.Contains(t, content, "original")
}
-
-func TestV2GitStore_UpdateCommitted_CheckpointNotFound(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("bb44cc55dd66")
-
- // Update without prior write should return error
- err := store.UpdateCommitted(ctx, UpdateCommittedOptions{
- CheckpointID: cpID,
- SessionID: "nonexistent",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"hello"}`)),
- Agent: agent.AgentTypeClaudeCode,
- })
- require.Error(t, err)
-}
-
-func TestV2GitStore_UpdateCommitted_PreservesExistingTaskMetadataInFullCurrent(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("cc55dd66ee77")
-
- // Initial write creates checkpoint/session on both /main and /full/current.
- err := store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session-task-preserve",
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"initial"}`)),
- Prompts: []string{"first prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Inject task metadata into /full/current to emulate condensation-time task copy.
- refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
- parentHash, rootTreeHash, err := store.GetRefState(refName)
- require.NoError(t, err)
-
- taskPath := []string{string(cpID[:2]), string(cpID[2:]), "0", "tasks", "toolu_01TASK"}
- checkpointJSON := []byte(`{"session_id":"test-session-task-preserve","tool_use_id":"toolu_01TASK"}`)
- blobHash, err := CreateBlobFromContent(repo, checkpointJSON)
- require.NoError(t, err)
-
- newRootHash, err := UpdateSubtree(
- repo, rootTreeHash,
- taskPath,
- []object.TreeEntry{{Name: "checkpoint.json", Mode: filemode.Regular, Hash: blobHash}},
- UpdateSubtreeOptions{MergeMode: MergeKeepExisting},
- )
- require.NoError(t, err)
-
- authorName, authorEmail := GetGitAuthorFromRepo(repo)
- commitHash, err := CreateCommit(ctx, repo, newRootHash, parentHash,
- fmt.Sprintf("Checkpoint: %s (task metadata)\n", cpID), authorName, authorEmail)
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
-
- // Finalize checkpoint with full transcript (the stop-time path).
- err = store.UpdateCommitted(ctx, UpdateCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session-task-preserve",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"assistant","message":"finalized"}`)),
- Prompts: []string{"first prompt", "second prompt"},
- Agent: agent.AgentTypeClaudeCode,
- })
- require.NoError(t, err)
-
- // Task metadata should still exist after UpdateCommitted.
- fullTree := v2FullTree(t, repo)
- _, err = fullTree.File(cpID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json")
- require.NoError(t, err, "task metadata should be preserved on /full/current during UpdateCommitted")
-}
-
-func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- store.maxCheckpointsPerGeneration = 3 // Low threshold for testing
- ctx := context.Background()
-
- // Write 3 checkpoints — the 3rd should trigger rotation
- for i := range 3 {
- cpID := id.MustCheckpointID(fmt.Sprintf("%012x", i+1))
- err := store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: fmt.Sprintf("session-rot-%d", i),
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"cp":%d}`, i))),
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
- }
-
- // Verify an archived generation exists
- archived, err := store.ListArchivedGenerations()
- require.NoError(t, err)
- assert.Len(t, archived, 1, "one archived generation should exist after rotation")
-
- // Verify /full/current is now a fresh generation (empty tree, no generation.json)
- _, freshTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
- freshCount, err := store.CountCheckpointsInTree(freshTreeHash)
- require.NoError(t, err)
- assert.Equal(t, 0, freshCount, "fresh /full/current should have no checkpoints")
-
- // Verify the archived generation has 3 checkpoints
- _, archiveTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullRefPrefix + archived[0]))
- require.NoError(t, err)
- archiveCount, err := store.CountCheckpointsInTree(archiveTreeHash)
- require.NoError(t, err)
- assert.Equal(t, 3, archiveCount)
-
- // Write a 4th checkpoint — should land on the fresh /full/current
- cpID4 := id.MustCheckpointID("000000000004")
- err = store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID4,
- SessionID: "session-rot-3",
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte(`{"cp":3}`)),
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- _, newTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
- newCount, err := store.CountCheckpointsInTree(newTreeHash)
- require.NoError(t, err)
- assert.Equal(t, 1, newCount, "new checkpoint should be on fresh generation")
-}
-
-func TestWriteCommitted_NoRotationBelowThreshold(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- store.maxCheckpointsPerGeneration = 5
- ctx := context.Background()
-
- // Write 3 checkpoints (below threshold of 5)
- for i := range 3 {
- cpID := id.MustCheckpointID(fmt.Sprintf("%012x", i+100))
- err := store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: fmt.Sprintf("session-norot-%d", i),
- Strategy: "manual-commit",
- Agent: agent.AgentTypeClaudeCode,
- Transcript: redact.AlreadyRedacted([]byte(fmt.Sprintf(`{"cp":%d}`, i))),
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
- }
-
- // No rotation should have occurred
- archived, err := store.ListArchivedGenerations()
- require.NoError(t, err)
- assert.Empty(t, archived, "no archived generations should exist below threshold")
-
- _, noRotTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
- noRotCount, err := store.CountCheckpointsInTree(noRotTreeHash)
- require.NoError(t, err)
- assert.Equal(t, 3, noRotCount)
-}
-
-// TestV2GitStore_CleanupV1TranscriptFiles verifies that CleanupV1TranscriptFiles
-// removes legacy v1-named files (full.jsonl, full.jsonl.*, content_hash.txt)
-// from /full/current while preserving v2-named files.
-func TestV2GitStore_CleanupV1TranscriptFiles(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("851fcec4a874")
-
- // Write initial checkpoint (sets up both /main and /full/current with v2 naming).
- err := store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session-v1-cleanup",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":"initial"}` + "\n")),
- Agent: agent.AgentTypeClaudeCode,
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Inject v1-named files (full.jsonl, full.jsonl.001, content_hash.txt)
- // directly into the /full/current tree to simulate legacy data.
- refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
- parentHash, rootTreeHash, err := store.GetRefState(refName)
- require.NoError(t, err)
-
- basePath := cpID.Path() + "/"
- sessionPath := basePath + "0/"
-
- entries, err := store.gs.flattenCheckpointEntries(rootTreeHash, cpID.Path())
- require.NoError(t, err)
-
- v1Blob, err := CreateBlobFromContent(repo, []byte(`{"type":"human","message":"v1 data"}`+"\n"))
- require.NoError(t, err)
- v1HashBlob, err := CreateBlobFromContent(repo, []byte("sha256:v1hash"))
- require.NoError(t, err)
- v1ChunkBlob, err := CreateBlobFromContent(repo, []byte(`{"type":"assistant","message":"v1 chunk"}`+"\n"))
- require.NoError(t, err)
-
- entries[sessionPath+paths.TranscriptFileName] = object.TreeEntry{
- Name: sessionPath + paths.TranscriptFileName,
- Mode: filemode.Regular,
- Hash: v1Blob,
- }
- entries[sessionPath+paths.TranscriptFileName+".001"] = object.TreeEntry{
- Name: sessionPath + paths.TranscriptFileName + ".001",
- Mode: filemode.Regular,
- Hash: v1ChunkBlob,
- }
- entries[sessionPath+paths.ContentHashFileName] = object.TreeEntry{
- Name: sessionPath + paths.ContentHashFileName,
- Mode: filemode.Regular,
- Hash: v1HashBlob,
- }
-
- newTreeHash, err := store.gs.spliceCheckpointSubtree(ctx, rootTreeHash, cpID, basePath, entries)
- require.NoError(t, err)
- err = store.updateRef(ctx, refName, newTreeHash, parentHash, "Inject v1 files", "Test", "test@test.com")
- require.NoError(t, err)
-
- // Verify v1-named files exist before cleanup.
- tree := v2FullTree(t, repo)
- cpPath := cpID.Path()
- sessionTree, err := tree.Tree(cpPath + "/0")
- require.NoError(t, err)
- preCleanup := make(map[string]bool)
- for _, entry := range sessionTree.Entries {
- preCleanup[entry.Name] = true
- }
- assert.True(t, preCleanup[paths.TranscriptFileName], "full.jsonl should exist before cleanup")
- assert.True(t, preCleanup[paths.TranscriptFileName+".001"], "full.jsonl.001 should exist before cleanup")
- assert.True(t, preCleanup[paths.ContentHashFileName], "content_hash.txt should exist before cleanup")
- assert.True(t, preCleanup[paths.V2RawTranscriptFileName], "raw_transcript should exist before cleanup")
-
- // Run cleanup.
- err = store.CleanupV1TranscriptFiles(ctx, cpID, 1)
- require.NoError(t, err)
-
- // Verify v1-named files are gone, v2-named files are preserved.
- tree = v2FullTree(t, repo)
- sessionTree, err = tree.Tree(cpPath + "/0")
- require.NoError(t, err)
-
- postCleanup := make(map[string]bool)
- for _, entry := range sessionTree.Entries {
- postCleanup[entry.Name] = true
- }
-
- assert.True(t, postCleanup[paths.V2RawTranscriptFileName], "raw_transcript should exist after cleanup")
- assert.True(t, postCleanup[paths.V2RawTranscriptHashFileName], "raw_transcript_hash.txt should exist after cleanup")
- assert.False(t, postCleanup[paths.TranscriptFileName], "full.jsonl should be removed after cleanup")
- assert.False(t, postCleanup[paths.TranscriptFileName+".001"], "full.jsonl.001 should be removed after cleanup")
- assert.False(t, postCleanup[paths.ContentHashFileName], "content_hash.txt should be removed after cleanup")
-}
-
-// TestV2GitStore_CleanupV1TranscriptFiles_NoopWhenClean verifies that
-// CleanupV1TranscriptFiles is a no-op when no v1 files exist.
-func TestV2GitStore_CleanupV1TranscriptFiles_NoopWhenClean(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("962fcec4a874")
-
- err := store.WriteCommitted(ctx, WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session-noop",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":"clean"}` + "\n")),
- Agent: agent.AgentTypeClaudeCode,
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Get tree hash before cleanup.
- _, treeBefore, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
-
- // Cleanup should be a no-op (no v1 files to remove).
- err = store.CleanupV1TranscriptFiles(ctx, cpID, 1)
- require.NoError(t, err)
-
- // Tree hash should be unchanged (no commit created).
- _, treeAfter, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
- assert.Equal(t, treeBefore, treeAfter, "tree should be unchanged when no v1 files exist")
-}
-
-func TestV2GitStore_CleanupV1TranscriptFiles_ReturnsCorruptRefError(t *testing.T) {
- t.Parallel()
- repo := initTestRepo(t)
- store := NewV2GitStore(repo, "origin")
-
- refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
- missingCommit := plumbing.NewHash("1111111111111111111111111111111111111111")
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, missingCommit)))
-
- err := store.CleanupV1TranscriptFiles(context.Background(), id.MustCheckpointID("962fcec4a874"), 1)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to get commit")
-}
diff --git a/cli/clean_2_test.go b/cli/clean_2_test.go
new file mode 100644
index 0000000..1761e88
--- /dev/null
+++ b/cli/clean_2_test.go
@@ -0,0 +1,625 @@
+package cli
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+func TestCleanCmd_All_SessionsBranchPreserved(t *testing.T) {
+ repo, commitHash := setupCleanTestRepo(t)
+
+ shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
+ if err := repo.Storer.SetReference(shadowRef); err != nil {
+ t.Fatalf("failed to create shadow branch: %v", err)
+ }
+
+ sessionsRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), commitHash)
+ if err := repo.Storer.SetReference(sessionsRef); err != nil {
+ t.Fatalf("failed to create trace/checkpoints/v1: %v", err)
+ }
+
+ cmd := newCleanCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ err := cmd.Execute()
+ if err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ // Shadow branch should be deleted
+ refName := plumbing.NewBranchReferenceName("trace/abc1234")
+ if _, err := repo.Reference(refName, true); err == nil {
+ t.Error("Shadow branch should be deleted")
+ }
+
+ // Sessions branch should still exist
+ sessionsRefName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ if _, err := repo.Reference(sessionsRefName, true); err != nil {
+ t.Error("trace/checkpoints/v1 branch should be preserved")
+ }
+}
+
+func TestCleanCmd_All_NotGitRepository(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ paths.ClearWorktreeRootCache()
+
+ cmd := newCleanCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"--all"})
+
+ err := cmd.Execute()
+ // Should return error for non-git directory
+ if err == nil {
+ t.Error("clean --all should return error for non-git directory")
+ }
+}
+
+func TestCleanCmd_All_InvalidSettingsWarnsAndContinues(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true,`)
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --dry-run error = %v", err)
+ }
+
+ if !strings.Contains(stderr.String(), "Warning: failed to load settings") {
+ t.Fatalf("expected settings warning, got stderr=%q", stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "No items to clean up.") {
+ t.Fatalf("expected command to continue cleanup flow, got stdout=%q", stdout.String())
+ }
+}
+
+func TestCleanCmd_All_Subdirectory(t *testing.T) {
+ repo, commitHash := setupCleanTestRepo(t)
+
+ shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
+ if err := repo.Storer.SetReference(shadowRef); err != nil {
+ t.Fatalf("failed to create shadow branch: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+ subDir := filepath.Join(repoRoot, "subdir")
+ if err := wt.Filesystem.MkdirAll("subdir", 0o755); err != nil {
+ t.Fatalf("failed to create subdir: %v", err)
+ }
+
+ t.Chdir(subDir)
+ paths.ClearWorktreeRootCache()
+
+ cmd := newCleanCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ err = cmd.Execute()
+ if err != nil {
+ t.Fatalf("clean --all --dry-run from subdirectory error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "trace/abc1234") {
+ t.Errorf("Should find shadow branches from subdirectory, got: %s", output)
+ }
+}
+
+// Regression test: --all should find sessions that have a shadow branch.
+// Previously, --all only cleaned orphaned sessions (no shadow branch AND no checkpoints),
+// so active sessions with a shadow branch were invisible to --all.
+func TestCleanCmd_All_FindsSessionWithShadowBranch(t *testing.T) {
+ repo, commitHash := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ worktreePath := wt.Filesystem.Root()
+ worktreeID, err := paths.GetWorktreeID(worktreePath)
+ if err != nil {
+ t.Fatalf("failed to get worktree ID: %v", err)
+ }
+
+ // Create shadow branch for the session's base commit
+ shadowBranch := checkpoint.ShadowBranchNameForCommit(commitHash.String(), worktreeID)
+ shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), commitHash)
+ if err := repo.Storer.SetReference(shadowRef); err != nil {
+ t.Fatalf("failed to create shadow branch: %v", err)
+ }
+
+ // Create session state file — this session HAS a shadow branch,
+ // so it was NOT considered orphaned by the old --all behavior
+ sessionFile := createSessionStateFile(t, worktreePath, "2026-02-02-active-session", commitHash)
+
+ cmd := newCleanCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ err = cmd.Execute()
+ if err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ output := stdout.String()
+
+ // Session should be cleaned
+ if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
+ t.Error("session state file should be deleted by --all")
+ }
+
+ // Shadow branch should be cleaned
+ refName := plumbing.NewBranchReferenceName(shadowBranch)
+ if _, err := repo.Reference(refName, true); err == nil {
+ t.Error("shadow branch should be deleted by --all")
+ }
+
+ if !strings.Contains(output, "Deleted") {
+ t.Errorf("Expected 'Deleted' in output, got: %s", output)
+ }
+}
+
+func TestCleanCmd_All_DryRunListsEligibleV2Generations(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ createArchivedGenerationRef(t, repo, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --dry-run error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Archived v2 generations (1):") {
+ t.Fatalf("expected archived v2 generation section, got: %s", output)
+ }
+ if !strings.Contains(output, "0000000000001") {
+ t.Fatalf("expected archived generation ref in output, got: %s", output)
+ }
+}
+
+func TestCleanCmd_All_DryRunListsRemoteOnlyEligibleV2Generations(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ addCleanBareOrigin(t, repoRoot)
+ createRemoteOnlyArchivedGenerationRef(t, repo, repoRoot, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --dry-run error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Archived v2 generations (1):") {
+ t.Fatalf("expected archived v2 generation section, got: %s", output)
+ }
+ if !strings.Contains(output, "0000000000001") {
+ t.Fatalf("expected remote-only archived generation ref in output, got: %s", output)
+ }
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000001"), true); err == nil {
+ t.Fatal("dry-run should not leave remote-only archived generation as a local ref")
+ }
+ if _, err := repo.Reference(plumbing.ReferenceName("refs/trace-clean-tmp/v2/full/0000000000001"), true); err == nil {
+ t.Fatal("dry-run should remove temporary fetched generation ref")
+ }
+}
+
+func TestCleanCmd_All_UsesRawTranscriptTimeForV2GenerationRetention(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+
+ cpID := id.MustCheckpointID("aabbccddeeff")
+ createV2MainMetadataRef(t, repo, cpID, time.Now())
+ createArchivedGenerationRefWithRawTranscript(t, repo, "0000000000005", cpID,
+ time.Now(), time.Now(),
+ time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --dry-run error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Archived v2 generations (1):") {
+ t.Fatalf("expected archived v2 generation section, got: %s", output)
+ }
+ if !strings.Contains(output, "0000000000005") {
+ t.Fatalf("expected generation to be eligible by raw transcript timestamps, got: %s", output)
+ }
+}
+
+func TestCleanCmd_All_ForceDeletesRemoteOnlyEligibleV2Generations(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ addCleanBareOrigin(t, repoRoot)
+ refOID := createRemoteOnlyArchivedGenerationRef(t, repo, repoRoot, "0000000000006", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000006"), true); err == nil {
+ t.Fatal("remote-only archived generation should not be left locally")
+ }
+ remoteOutput := runCleanGit(t, repoRoot, "ls-remote", "origin", paths.V2FullRefPrefix+"0000000000006")
+ if strings.Contains(remoteOutput, refOID) {
+ t.Fatalf("expected remote archived generation to be deleted, got: %s", remoteOutput)
+ }
+ if !strings.Contains(stdout.String(), "Archived v2 generations") {
+ t.Fatalf("expected deletion output to include archived v2 generations, got: %s", stdout.String())
+ }
+}
+
+func TestCleanCmd_All_ForceDeletesEligibleV2Generations(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2MainRefName))
+ createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ createArchivedGenerationRef(t, repo, "0000000000002", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000002"), true); err == nil {
+ t.Fatal("archived v2 generation ref should be deleted")
+ }
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true); err != nil {
+ t.Fatalf("v2 main ref should remain: %v", err)
+ }
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true); err != nil {
+ t.Fatalf("v2 full current ref should remain: %v", err)
+ }
+}
+
+func TestCleanCmd_All_DryRunSkipsV2GenerationsWithinRetention(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ createArchivedGenerationRef(t, repo, "0000000000003", time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -1))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--dry-run"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --dry-run error = %v", err)
+ }
+
+ output := stdout.String()
+ if strings.Contains(output, "Archived v2 generations") {
+ t.Fatalf("did not expect archived v2 generation section for retained generation, got: %s", output)
+ }
+ if strings.Contains(output, "0000000000003") {
+ t.Fatalf("did not expect retained generation ref in output, got: %s", output)
+ }
+}
+
+func TestCleanCmd_All_ForceSkipsV2GenerationMissingMetadata(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ createArchivedGenerationRefWithoutMetadata(t, repo, "0000000000001")
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000001"), true); err != nil {
+ t.Fatalf("archived generation ref with missing metadata should remain: %v", err)
+ }
+ if !strings.Contains(stderr.String(), "missing generation.json") {
+ t.Fatalf("expected missing generation warning, got stdout=%q stderr=%q", stdout.String(), stderr.String())
+ }
+}
+
+func TestCleanCmd_All_ForceSkipsV2GenerationWithInvalidTimestamps(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+ createArchivedGenerationRef(t, repo, "0000000000004", time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -20))
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000004"), true); err != nil {
+ t.Fatalf("archived generation ref with invalid timestamps should remain: %v", err)
+ }
+ if !strings.Contains(stderr.String(), "invalid timestamps") {
+ t.Fatalf("expected invalid timestamp warning, got stdout=%q stderr=%q", stdout.String(), stderr.String())
+ }
+}
+
+func TestCleanCmd_All_ForceWarnsWithErrorDetailsForUnreadableV2Ref(t *testing.T) {
+ repo, _ := setupCleanTestRepo(t)
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ repoRoot := wt.Filesystem.Root()
+
+ writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
+
+ genName := "0000000000010"
+ refName := plumbing.ReferenceName(paths.V2FullRefPrefix + genName)
+ brokenHash := plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, brokenHash)); err != nil {
+ t.Fatalf("failed to create broken archived generation ref: %v", err)
+ }
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("clean --all --force error = %v", err)
+ }
+
+ warningText := stderr.String()
+ if !strings.Contains(warningText, "generation "+genName+": cannot read ref:") {
+ t.Fatalf("expected warning with ref error details, got stdout=%q stderr=%q", stdout.String(), warningText)
+ }
+}
+
+// --- runCleanAllWithItems unit tests ---
+
+func TestRunCleanAllWithItems_PartialFailure(t *testing.T) {
+ repo, commitHash := setupCleanTestRepo(t)
+
+ shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
+ if err := repo.Storer.SetReference(shadowRef); err != nil {
+ t.Fatalf("failed to create shadow branch: %v", err)
+ }
+
+ items := []strategy.CleanupItem{
+ {Type: strategy.CleanupTypeShadowBranch, ID: "trace/abc1234", Reason: "test"},
+ {Type: strategy.CleanupTypeShadowBranch, ID: "trace/nonexistent1234567", Reason: "test"},
+ }
+
+ cmd, stdout, stderr := newTestCleanCmd(t)
+ err := runCleanAllWithItems(cmd.Context(), cmd, true, false, items, nil)
+
+ if err == nil {
+ t.Fatal("runCleanAllWithItems() should return error when items fail to delete")
+ }
+ if !strings.Contains(err.Error(), "failed to delete 1 item") {
+ t.Errorf("Error should mention 'failed to delete 1 item', got: %v", err)
+ }
+ // Verify singular (not "1 items")
+ if strings.Contains(err.Error(), "1 items") {
+ t.Errorf("Error should use singular 'item' for count 1, got: %v", err)
+ }
+
+ // Output should show the successful deletion with singular grammar
+ output := stdout.String()
+ if !strings.Contains(output, "✓ Deleted 1 item:") {
+ t.Errorf("Output should show '✓ Deleted 1 item:', got: %s", output)
+ }
+ // Stderr should show the failure with singular grammar
+ errOutput := stderr.String()
+ if !strings.Contains(errOutput, "Failed to delete 1 item:") {
+ t.Errorf("Stderr should show 'Failed to delete 1 item:', got: %s", errOutput)
+ }
+}
+
+func TestRunCleanAllWithItems_AllFailures(t *testing.T) {
+ setupCleanTestRepo(t)
+
+ items := []strategy.CleanupItem{
+ {Type: strategy.CleanupTypeShadowBranch, ID: "trace/nonexistent1234567", Reason: "test"},
+ {Type: strategy.CleanupTypeShadowBranch, ID: "trace/alsononexistent", Reason: "test"},
+ }
+
+ cmd, stdout, stderr := newTestCleanCmd(t)
+ err := runCleanAllWithItems(cmd.Context(), cmd, true, false, items, nil)
+
+ if err == nil {
+ t.Fatal("runCleanAllWithItems() should return error when items fail to delete")
+ }
+ if !strings.Contains(err.Error(), "failed to delete 2 items") {
+ t.Errorf("Error should mention 'failed to delete 2 items', got: %v", err)
+ }
+
+ output := stdout.String()
+ if strings.Contains(output, "✓ Deleted") {
+ t.Errorf("Output should not show successful deletions, got: %s", output)
+ }
+ // Failures are written to stderr
+ errOutput := stderr.String()
+ if !strings.Contains(errOutput, "Failed to delete 2 items:") {
+ t.Errorf("Stderr should show 'Failed to delete 2 items:', got: %s", errOutput)
+ }
+}
+
+func TestRunCleanAllWithItems_NoItems(t *testing.T) {
+ setupCleanTestRepo(t)
+
+ cmd, stdout, _ := newTestCleanCmd(t)
+ err := runCleanAllWithItems(cmd.Context(), cmd, false, false, []strategy.CleanupItem{}, nil)
+ if err != nil {
+ t.Fatalf("runCleanAllWithItems() error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "No items to clean up") {
+ t.Errorf("Expected 'No items to clean up' message, got: %s", output)
+ }
+}
+
+func TestRunCleanAllWithItems_MixedTypes_Preview(t *testing.T) {
+ setupCleanTestRepo(t)
+
+ items := []strategy.CleanupItem{
+ {Type: strategy.CleanupTypeShadowBranch, ID: "trace/abc1234", Reason: "test"},
+ {Type: strategy.CleanupTypeSessionState, ID: "session-123", Reason: "no checkpoints"},
+ {Type: strategy.CleanupTypeCheckpoint, ID: "checkpoint-abc", Reason: "orphaned"},
+ }
+
+ cmd, stdout, _ := newTestCleanCmd(t)
+ err := runCleanAllWithItems(cmd.Context(), cmd, false, true, items, nil)
+ if err != nil {
+ t.Fatalf("runCleanAllWithItems() error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Shadow branches") {
+ t.Errorf("Expected 'Shadow branches' section, got: %s", output)
+ }
+ if !strings.Contains(output, "Session states") {
+ t.Errorf("Expected 'Session states' section, got: %s", output)
+ }
+ if !strings.Contains(output, "Checkpoint metadata") {
+ t.Errorf("Expected 'Checkpoint metadata' section, got: %s", output)
+ }
+ if !strings.Contains(output, "Found 3 items to clean") {
+ t.Errorf("Expected 'Found 3 items to clean', got: %s", output)
+ }
+}
+
+// --- Flag validation tests ---
+
+func TestCleanCmd_MutuallyExclusiveFlags(t *testing.T) {
+ setupCleanTestRepo(t)
+
+ cmd := newCleanCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--all", "--session", "test-session"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("--all and --session should be mutually exclusive")
+ }
+ if !strings.Contains(err.Error(), "cannot be used together") {
+ t.Errorf("Expected mutual exclusion error, got: %v", err)
+ }
+}
diff --git a/cli/clean_test.go b/cli/clean_test.go
index 07d23fd..a74c3d6 100644
--- a/cli/clean_test.go
+++ b/cli/clean_test.go
@@ -820,612 +820,3 @@ func TestCleanCmd_All_ForceMode(t *testing.T) {
}
}
}
-
-func TestCleanCmd_All_SessionsBranchPreserved(t *testing.T) {
- repo, commitHash := setupCleanTestRepo(t)
-
- shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
- if err := repo.Storer.SetReference(shadowRef); err != nil {
- t.Fatalf("failed to create shadow branch: %v", err)
- }
-
- sessionsRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), commitHash)
- if err := repo.Storer.SetReference(sessionsRef); err != nil {
- t.Fatalf("failed to create trace/checkpoints/v1: %v", err)
- }
-
- cmd := newCleanCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"--all", "--force"})
-
- err := cmd.Execute()
- if err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- // Shadow branch should be deleted
- refName := plumbing.NewBranchReferenceName("trace/abc1234")
- if _, err := repo.Reference(refName, true); err == nil {
- t.Error("Shadow branch should be deleted")
- }
-
- // Sessions branch should still exist
- sessionsRefName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- if _, err := repo.Reference(sessionsRefName, true); err != nil {
- t.Error("trace/checkpoints/v1 branch should be preserved")
- }
-}
-
-func TestCleanCmd_All_NotGitRepository(t *testing.T) {
- dir := t.TempDir()
- t.Chdir(dir)
- paths.ClearWorktreeRootCache()
-
- cmd := newCleanCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"--all"})
-
- err := cmd.Execute()
- // Should return error for non-git directory
- if err == nil {
- t.Error("clean --all should return error for non-git directory")
- }
-}
-
-func TestCleanCmd_All_InvalidSettingsWarnsAndContinues(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true,`)
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --dry-run error = %v", err)
- }
-
- if !strings.Contains(stderr.String(), "Warning: failed to load settings") {
- t.Fatalf("expected settings warning, got stderr=%q", stderr.String())
- }
- if !strings.Contains(stdout.String(), "No items to clean up.") {
- t.Fatalf("expected command to continue cleanup flow, got stdout=%q", stdout.String())
- }
-}
-
-func TestCleanCmd_All_Subdirectory(t *testing.T) {
- repo, commitHash := setupCleanTestRepo(t)
-
- shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
- if err := repo.Storer.SetReference(shadowRef); err != nil {
- t.Fatalf("failed to create shadow branch: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
- subDir := filepath.Join(repoRoot, "subdir")
- if err := wt.Filesystem.MkdirAll("subdir", 0o755); err != nil {
- t.Fatalf("failed to create subdir: %v", err)
- }
-
- t.Chdir(subDir)
- paths.ClearWorktreeRootCache()
-
- cmd := newCleanCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- err = cmd.Execute()
- if err != nil {
- t.Fatalf("clean --all --dry-run from subdirectory error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "trace/abc1234") {
- t.Errorf("Should find shadow branches from subdirectory, got: %s", output)
- }
-}
-
-// Regression test: --all should find sessions that have a shadow branch.
-// Previously, --all only cleaned orphaned sessions (no shadow branch AND no checkpoints),
-// so active sessions with a shadow branch were invisible to --all.
-func TestCleanCmd_All_FindsSessionWithShadowBranch(t *testing.T) {
- repo, commitHash := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- worktreePath := wt.Filesystem.Root()
- worktreeID, err := paths.GetWorktreeID(worktreePath)
- if err != nil {
- t.Fatalf("failed to get worktree ID: %v", err)
- }
-
- // Create shadow branch for the session's base commit
- shadowBranch := checkpoint.ShadowBranchNameForCommit(commitHash.String(), worktreeID)
- shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), commitHash)
- if err := repo.Storer.SetReference(shadowRef); err != nil {
- t.Fatalf("failed to create shadow branch: %v", err)
- }
-
- // Create session state file — this session HAS a shadow branch,
- // so it was NOT considered orphaned by the old --all behavior
- sessionFile := createSessionStateFile(t, worktreePath, "2026-02-02-active-session", commitHash)
-
- cmd := newCleanCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"--all", "--force"})
-
- err = cmd.Execute()
- if err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- output := stdout.String()
-
- // Session should be cleaned
- if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
- t.Error("session state file should be deleted by --all")
- }
-
- // Shadow branch should be cleaned
- refName := plumbing.NewBranchReferenceName(shadowBranch)
- if _, err := repo.Reference(refName, true); err == nil {
- t.Error("shadow branch should be deleted by --all")
- }
-
- if !strings.Contains(output, "Deleted") {
- t.Errorf("Expected 'Deleted' in output, got: %s", output)
- }
-}
-
-func TestCleanCmd_All_DryRunListsEligibleV2Generations(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- createArchivedGenerationRef(t, repo, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --dry-run error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Archived v2 generations (1):") {
- t.Fatalf("expected archived v2 generation section, got: %s", output)
- }
- if !strings.Contains(output, "0000000000001") {
- t.Fatalf("expected archived generation ref in output, got: %s", output)
- }
-}
-
-func TestCleanCmd_All_DryRunListsRemoteOnlyEligibleV2Generations(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- addCleanBareOrigin(t, repoRoot)
- createRemoteOnlyArchivedGenerationRef(t, repo, repoRoot, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --dry-run error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Archived v2 generations (1):") {
- t.Fatalf("expected archived v2 generation section, got: %s", output)
- }
- if !strings.Contains(output, "0000000000001") {
- t.Fatalf("expected remote-only archived generation ref in output, got: %s", output)
- }
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000001"), true); err == nil {
- t.Fatal("dry-run should not leave remote-only archived generation as a local ref")
- }
- if _, err := repo.Reference(plumbing.ReferenceName("refs/trace-clean-tmp/v2/full/0000000000001"), true); err == nil {
- t.Fatal("dry-run should remove temporary fetched generation ref")
- }
-}
-
-func TestCleanCmd_All_UsesRawTranscriptTimeForV2GenerationRetention(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
-
- cpID := id.MustCheckpointID("aabbccddeeff")
- createV2MainMetadataRef(t, repo, cpID, time.Now())
- createArchivedGenerationRefWithRawTranscript(t, repo, "0000000000005", cpID,
- time.Now(), time.Now(),
- time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --dry-run error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Archived v2 generations (1):") {
- t.Fatalf("expected archived v2 generation section, got: %s", output)
- }
- if !strings.Contains(output, "0000000000005") {
- t.Fatalf("expected generation to be eligible by raw transcript timestamps, got: %s", output)
- }
-}
-
-func TestCleanCmd_All_ForceDeletesRemoteOnlyEligibleV2Generations(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- addCleanBareOrigin(t, repoRoot)
- refOID := createRemoteOnlyArchivedGenerationRef(t, repo, repoRoot, "0000000000006", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000006"), true); err == nil {
- t.Fatal("remote-only archived generation should not be left locally")
- }
- remoteOutput := runCleanGit(t, repoRoot, "ls-remote", "origin", paths.V2FullRefPrefix+"0000000000006")
- if strings.Contains(remoteOutput, refOID) {
- t.Fatalf("expected remote archived generation to be deleted, got: %s", remoteOutput)
- }
- if !strings.Contains(stdout.String(), "Archived v2 generations") {
- t.Fatalf("expected deletion output to include archived v2 generations, got: %s", stdout.String())
- }
-}
-
-func TestCleanCmd_All_ForceDeletesEligibleV2Generations(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2MainRefName))
- createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2FullCurrentRefName))
- createArchivedGenerationRef(t, repo, "0000000000002", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000002"), true); err == nil {
- t.Fatal("archived v2 generation ref should be deleted")
- }
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true); err != nil {
- t.Fatalf("v2 main ref should remain: %v", err)
- }
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true); err != nil {
- t.Fatalf("v2 full current ref should remain: %v", err)
- }
-}
-
-func TestCleanCmd_All_DryRunSkipsV2GenerationsWithinRetention(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- createArchivedGenerationRef(t, repo, "0000000000003", time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -1))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--dry-run"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --dry-run error = %v", err)
- }
-
- output := stdout.String()
- if strings.Contains(output, "Archived v2 generations") {
- t.Fatalf("did not expect archived v2 generation section for retained generation, got: %s", output)
- }
- if strings.Contains(output, "0000000000003") {
- t.Fatalf("did not expect retained generation ref in output, got: %s", output)
- }
-}
-
-func TestCleanCmd_All_ForceSkipsV2GenerationMissingMetadata(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- createArchivedGenerationRefWithoutMetadata(t, repo, "0000000000001")
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000001"), true); err != nil {
- t.Fatalf("archived generation ref with missing metadata should remain: %v", err)
- }
- if !strings.Contains(stderr.String(), "missing generation.json") {
- t.Fatalf("expected missing generation warning, got stdout=%q stderr=%q", stdout.String(), stderr.String())
- }
-}
-
-func TestCleanCmd_All_ForceSkipsV2GenerationWithInvalidTimestamps(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
- createArchivedGenerationRef(t, repo, "0000000000004", time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -20))
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- if _, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000004"), true); err != nil {
- t.Fatalf("archived generation ref with invalid timestamps should remain: %v", err)
- }
- if !strings.Contains(stderr.String(), "invalid timestamps") {
- t.Fatalf("expected invalid timestamp warning, got stdout=%q stderr=%q", stdout.String(), stderr.String())
- }
-}
-
-func TestCleanCmd_All_ForceWarnsWithErrorDetailsForUnreadableV2Ref(t *testing.T) {
- repo, _ := setupCleanTestRepo(t)
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- repoRoot := wt.Filesystem.Root()
-
- writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`)
-
- genName := "0000000000010"
- refName := plumbing.ReferenceName(paths.V2FullRefPrefix + genName)
- brokenHash := plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
- if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, brokenHash)); err != nil {
- t.Fatalf("failed to create broken archived generation ref: %v", err)
- }
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("clean --all --force error = %v", err)
- }
-
- warningText := stderr.String()
- if !strings.Contains(warningText, "generation "+genName+": cannot read ref:") {
- t.Fatalf("expected warning with ref error details, got stdout=%q stderr=%q", stdout.String(), warningText)
- }
-}
-
-// --- runCleanAllWithItems unit tests ---
-
-func TestRunCleanAllWithItems_PartialFailure(t *testing.T) {
- repo, commitHash := setupCleanTestRepo(t)
-
- shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName("trace/abc1234"), commitHash)
- if err := repo.Storer.SetReference(shadowRef); err != nil {
- t.Fatalf("failed to create shadow branch: %v", err)
- }
-
- items := []strategy.CleanupItem{
- {Type: strategy.CleanupTypeShadowBranch, ID: "trace/abc1234", Reason: "test"},
- {Type: strategy.CleanupTypeShadowBranch, ID: "trace/nonexistent1234567", Reason: "test"},
- }
-
- cmd, stdout, stderr := newTestCleanCmd(t)
- err := runCleanAllWithItems(cmd.Context(), cmd, true, false, items, nil)
-
- if err == nil {
- t.Fatal("runCleanAllWithItems() should return error when items fail to delete")
- }
- if !strings.Contains(err.Error(), "failed to delete 1 item") {
- t.Errorf("Error should mention 'failed to delete 1 item', got: %v", err)
- }
- // Verify singular (not "1 items")
- if strings.Contains(err.Error(), "1 items") {
- t.Errorf("Error should use singular 'item' for count 1, got: %v", err)
- }
-
- // Output should show the successful deletion with singular grammar
- output := stdout.String()
- if !strings.Contains(output, "✓ Deleted 1 item:") {
- t.Errorf("Output should show '✓ Deleted 1 item:', got: %s", output)
- }
- // Stderr should show the failure with singular grammar
- errOutput := stderr.String()
- if !strings.Contains(errOutput, "Failed to delete 1 item:") {
- t.Errorf("Stderr should show 'Failed to delete 1 item:', got: %s", errOutput)
- }
-}
-
-func TestRunCleanAllWithItems_AllFailures(t *testing.T) {
- setupCleanTestRepo(t)
-
- items := []strategy.CleanupItem{
- {Type: strategy.CleanupTypeShadowBranch, ID: "trace/nonexistent1234567", Reason: "test"},
- {Type: strategy.CleanupTypeShadowBranch, ID: "trace/alsononexistent", Reason: "test"},
- }
-
- cmd, stdout, stderr := newTestCleanCmd(t)
- err := runCleanAllWithItems(cmd.Context(), cmd, true, false, items, nil)
-
- if err == nil {
- t.Fatal("runCleanAllWithItems() should return error when items fail to delete")
- }
- if !strings.Contains(err.Error(), "failed to delete 2 items") {
- t.Errorf("Error should mention 'failed to delete 2 items', got: %v", err)
- }
-
- output := stdout.String()
- if strings.Contains(output, "✓ Deleted") {
- t.Errorf("Output should not show successful deletions, got: %s", output)
- }
- // Failures are written to stderr
- errOutput := stderr.String()
- if !strings.Contains(errOutput, "Failed to delete 2 items:") {
- t.Errorf("Stderr should show 'Failed to delete 2 items:', got: %s", errOutput)
- }
-}
-
-func TestRunCleanAllWithItems_NoItems(t *testing.T) {
- setupCleanTestRepo(t)
-
- cmd, stdout, _ := newTestCleanCmd(t)
- err := runCleanAllWithItems(cmd.Context(), cmd, false, false, []strategy.CleanupItem{}, nil)
- if err != nil {
- t.Fatalf("runCleanAllWithItems() error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "No items to clean up") {
- t.Errorf("Expected 'No items to clean up' message, got: %s", output)
- }
-}
-
-func TestRunCleanAllWithItems_MixedTypes_Preview(t *testing.T) {
- setupCleanTestRepo(t)
-
- items := []strategy.CleanupItem{
- {Type: strategy.CleanupTypeShadowBranch, ID: "trace/abc1234", Reason: "test"},
- {Type: strategy.CleanupTypeSessionState, ID: "session-123", Reason: "no checkpoints"},
- {Type: strategy.CleanupTypeCheckpoint, ID: "checkpoint-abc", Reason: "orphaned"},
- }
-
- cmd, stdout, _ := newTestCleanCmd(t)
- err := runCleanAllWithItems(cmd.Context(), cmd, false, true, items, nil)
- if err != nil {
- t.Fatalf("runCleanAllWithItems() error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Shadow branches") {
- t.Errorf("Expected 'Shadow branches' section, got: %s", output)
- }
- if !strings.Contains(output, "Session states") {
- t.Errorf("Expected 'Session states' section, got: %s", output)
- }
- if !strings.Contains(output, "Checkpoint metadata") {
- t.Errorf("Expected 'Checkpoint metadata' section, got: %s", output)
- }
- if !strings.Contains(output, "Found 3 items to clean") {
- t.Errorf("Expected 'Found 3 items to clean', got: %s", output)
- }
-}
-
-// --- Flag validation tests ---
-
-func TestCleanCmd_MutuallyExclusiveFlags(t *testing.T) {
- setupCleanTestRepo(t)
-
- cmd := newCleanCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--all", "--session", "test-session"})
-
- err := cmd.Execute()
- if err == nil {
- t.Fatal("--all and --session should be mutually exclusive")
- }
- if !strings.Contains(err.Error(), "cannot be used together") {
- t.Errorf("Expected mutual exclusion error, got: %v", err)
- }
-}
diff --git a/cli/explain.go b/cli/explain.go
index a345c95..2c2167b 100644
--- a/cli/explain.go
+++ b/cli/explain.go
@@ -7,42 +7,22 @@ import (
"fmt"
"io"
"log/slog"
- "os"
- "os/exec"
- "runtime"
- "sort"
- "strconv"
"strings"
"time"
- "github.com/GrayCodeAI/trace/cli/agent"
- "github.com/GrayCodeAI/trace/cli/agent/claudecode"
- "github.com/GrayCodeAI/trace/cli/agent/external"
- "github.com/GrayCodeAI/trace/cli/agent/geminicli"
- "github.com/GrayCodeAI/trace/cli/agent/opencode"
- "github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
- "github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/strategy"
"github.com/GrayCodeAI/trace/cli/summarize"
"github.com/GrayCodeAI/trace/cli/trailers"
- "github.com/GrayCodeAI/trace/cli/transcript"
- transcriptcompact "github.com/GrayCodeAI/trace/cli/transcript/compact"
- "github.com/GrayCodeAI/trace/redact"
- "charm.land/lipgloss/v2"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
- "github.com/go-git/go-git/v6/plumbing/storer"
"github.com/go-git/go-git/v6/storage/filesystem"
"github.com/spf13/cobra"
- "golang.org/x/term"
)
const defaultCheckpointSummaryTimeout = 30 * time.Second
@@ -823,2130 +803,3 @@ func loadV2MainRootTree(repo *git.Repository) (*object.Tree, error) {
}
return tree, nil
}
-
-func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, error) {
- repo, err := openRepository(ctx)
- if err != nil {
- return nil, fmt.Errorf("not a git repository: %w", err)
- }
-
- v2URL, err := remote.FetchURL(ctx)
- if err != nil {
- logging.Debug(
- ctx, "explain: using origin for v2 store fetch remote",
- slog.String("error", err.Error()),
- )
- v2URL = ""
- }
-
- // FetchBlobsByHash uses `git fetch-pack` for blob SHAs (porcelain
- // `git fetch` fails against partial-clone repos with "did not send all
- // necessary objects"). Falls back to a full metadata-branch fetch if
- // fetch-pack also can't reach the blobs.
- v1Store := checkpoint.NewGitStore(repo)
- v1Store.SetBlobFetcher(FetchBlobsByHash)
-
- v2Store := checkpoint.NewV2GitStore(repo, v2URL)
- v2Store.SetBlobFetcher(FetchBlobsByHash)
-
- lookup := &explainCheckpointLookup{
- repo: repo,
- v1Store: v1Store,
- v2Store: v2Store,
- preferCheckpointsV2: settings.IsCheckpointsV2Enabled(ctx),
- }
-
- committed, err := listCommittedForExplain(ctx, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2)
- if err != nil {
- return nil, fmt.Errorf("failed to list checkpoints: %w", err)
- }
- lookup.committed = committed
- return lookup, nil
-}
-
-func listCommittedForExplain(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, preferCheckpointsV2 bool) ([]checkpoint.CommittedInfo, error) {
- v1Committed, v1Err := v1Store.ListCommitted(ctx)
-
- if !preferCheckpointsV2 {
- if v1Err != nil {
- return nil, fmt.Errorf("listing v1 checkpoints: %w", v1Err)
- }
- return v1Committed, nil
- }
-
- v2Committed, v2Err := v2Store.ListCommitted(ctx)
- if v2Err != nil {
- logging.Debug(
- ctx, "v2 ListCommitted failed, using v1 only",
- slog.String("error", v2Err.Error()),
- )
- if v1Err != nil {
- return nil, fmt.Errorf("listing checkpoints: %w", v1Err)
- }
- return v1Committed, nil
- }
-
- if v1Err != nil {
- logging.Debug(
- ctx, "v1 ListCommitted failed, returning v2 only",
- slog.String("error", v1Err.Error()),
- )
- return v2Committed, nil
- }
-
- // Merge v2 and v1 results so pre-v2 checkpoints remain visible during transition.
- seen := make(map[id.CheckpointID]struct{}, len(v2Committed))
- for _, c := range v2Committed {
- seen[c.CheckpointID] = struct{}{}
- }
- committedCheckpoints := make([]checkpoint.CommittedInfo, 0, len(v2Committed)+len(v1Committed))
- committedCheckpoints = append(committedCheckpoints, v2Committed...)
- for _, c := range v1Committed {
- if _, ok := seen[c.CheckpointID]; !ok {
- committedCheckpoints = append(committedCheckpoints, c)
- }
- }
- return committedCheckpoints, nil
-}
-
-func readLatestSessionContentForExplain(ctx context.Context, reader checkpoint.CommittedReader, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) {
- if summary == nil || len(summary.Sessions) == 0 {
- return nil, checkpoint.ErrCheckpointNotFound
- }
-
- latestIndex := len(summary.Sessions) - 1
- content, err := reader.ReadSessionContent(ctx, checkpointID, latestIndex)
- if err != nil {
- return nil, fmt.Errorf("reading session %d content: %w", latestIndex, err)
- }
- return content, nil
-}
-
-// resolvePromptTree picks the best metadata tree for reading session prompts.
-// Prefers v2 when enabled (same sharded layout as v1), falls back to v1.
-func resolvePromptTree(v1Tree, v2Tree *object.Tree, preferV2 bool) *object.Tree {
- if preferV2 && v2Tree != nil {
- return v2Tree
- }
- if v1Tree != nil {
- return v1Tree
- }
- return v2Tree // Last resort: use v2 even if not preferred
-}
-
-// readV2ContentFromMain reads session content from the v2 /main ref only —
-// metadata, prompts, and the compact transcript (transcript.jsonl). This is the
-// primary read path for default display modes that don't need the raw transcript
-// stored on /full/* refs.
-func readV2ContentFromMain(ctx context.Context, v2Reader *checkpoint.V2GitStore, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) {
- if summary == nil || len(summary.Sessions) == 0 {
- return nil, checkpoint.ErrCheckpointNotFound
- }
-
- latestIndex := len(summary.Sessions) - 1
-
- content, err := v2Reader.ReadSessionMetadataAndPrompts(ctx, checkpointID, latestIndex)
- if err != nil {
- return nil, fmt.Errorf("reading session %d metadata: %w", latestIndex, err)
- }
-
- // ReadSessionMetadataAndPrompts reads the compact transcript from the same
- // session tree. Reset transcript offsets when compact data is present.
- if len(content.Transcript) > 0 {
- content.Metadata.CheckpointTranscriptStart = 0
- //lint:ignore SA1019 // Set for backward compat with older CLI readers
- content.Metadata.TranscriptLinesAtStart = 0
- return content, nil
- }
-
- // No compact transcript on /main — fall back to the raw transcript on
- // /full/current for the most accurate display before resorting to prompt.txt.
- fullContent, fullErr := v2Reader.ReadSessionContent(ctx, checkpointID, latestIndex)
- if fullErr == nil && len(fullContent.Transcript) > 0 {
- content.Transcript = fullContent.Transcript
- return content, nil
- }
-
- // Last resort: return metadata + prompts without transcript.
- return content, nil
-}
-
-// generateCheckpointSummary generates an AI summary for a checkpoint and persists it.
-// The summary is generated from the scoped transcript (only this checkpoint's portion),
-// not the trace session transcript.
-func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error {
- // Check if summary already exists
- if content.Metadata.Summary != nil && !force {
- return renderExplainFailure(errW, "Summary already exists", []explainRow{
- {Label: "id", Value: checkpointID.String()},
- {Label: "try", Value: fmt.Sprintf("trace explain --generate --force %s", checkpointID)},
- }, fmt.Errorf("checkpoint %s already has a summary", checkpointID))
- }
-
- // Check if transcript exists
- if len(content.Transcript) == 0 {
- return renderExplainFailure(errW, "Checkpoint has no transcript", []explainRow{
- {Label: "id", Value: checkpointID.String()},
- }, fmt.Errorf("checkpoint %s has no transcript to summarize", checkpointID))
- }
-
- // Scope the transcript to only this checkpoint's portion
- scopedTranscript := scopeTranscriptForCheckpoint(content.Transcript, content.Metadata.GetTranscriptStart(), content.Metadata.Agent)
- if len(scopedTranscript) == 0 {
- return renderExplainFailure(errW, "Checkpoint has no transcript content (scoped)", []explainRow{
- {Label: "id", Value: checkpointID.String()},
- }, fmt.Errorf("checkpoint %s has no transcript content for this checkpoint (scoped)", checkpointID))
- }
- provider, err := resolveCheckpointSummaryProvider(ctx, w)
- if err != nil {
- return fmt.Errorf("failed to resolve summary provider: %w", err)
- }
- scopedTranscript = maybeCompactExternalTranscriptForSummary(ctx, scopedTranscript, content.Metadata.Agent)
-
- // Generate summary using shared helper
- logging.Info(ctx, "generating checkpoint summary")
- if errW != nil {
- fmt.Fprintln(errW, "Generating checkpoint summary...")
- }
-
- start := time.Now()
- summary, appliedDeadline, err := generateCheckpointAISummary(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, provider.Generator)
- if err != nil {
- label, rows, structured := formatCheckpointSummaryError(err, appliedDeadline)
- styles := newStatusStyles(errW)
- fmt.Fprint(errW, styles.renderFailure(label, rows))
- return NewSilentError(structured)
- }
- elapsed := time.Since(start)
-
- // Persist to both stores; at least one must succeed.
- v1Err := v1Store.UpdateSummary(ctx, checkpointID, summary)
- var v2Err error
- if v2Store != nil {
- v2Err = v2Store.UpdateSummary(ctx, checkpointID, summary)
- }
-
- switch {
- case v1Err != nil && (v2Store == nil || v2Err != nil):
- // No store succeeded — hard error.
- if v2Err != nil {
- return fmt.Errorf("failed to save summary: v1: %w, v2: %w", v1Err, v2Err)
- }
- return fmt.Errorf("failed to save summary: %w", v1Err)
- case v1Err != nil:
- logging.Debug(
- ctx, "v1 UpdateSummary failed (v2 succeeded)",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("error", v1Err.Error()),
- )
- case v2Err != nil:
- logging.Debug(
- ctx, "v2 UpdateSummary failed (v1 succeeded)",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("error", v2Err.Error()),
- )
- }
-
- styles := newStatusStyles(w)
- rows := summaryProviderRows(provider)
- rows = append(rows, explainRow{Label: "duration", Value: formatSummaryDuration(elapsed)})
- fmt.Fprint(w, styles.renderSuccess(fmt.Sprintf("Summary generated for %s", checkpointID), rows))
- return nil
-}
-
-// formatSummaryDuration rounds wall-clock generation time to a human-friendly value.
-func formatSummaryDuration(d time.Duration) string {
- return d.Round(100 * time.Millisecond).String()
-}
-
-func maybeCompactExternalTranscriptForSummary(ctx context.Context, scopedTranscript []byte, agentType types.AgentType) []byte {
- if transcriptHasSummaryContent(scopedTranscript, agentType) {
- return scopedTranscript
- }
-
- ag, err := agent.GetByAgentType(agentType)
- if err != nil {
- external.DiscoverAndRegister(ctx)
- ag, err = agent.GetByAgentType(agentType)
- }
- if err != nil || !external.IsExternal(ag) {
- return scopedTranscript
- }
-
- compactor, ok := agent.AsTranscriptCompactor(ag)
- if !ok {
- return scopedTranscript
- }
-
- tmpFile, err := os.CreateTemp("", "trace-summary-transcript-*.jsonl")
- if err != nil {
- logging.Debug(ctx, "external summary compaction unavailable",
- slog.String("agent", string(agentType)),
- slog.String("error", err.Error()))
- return scopedTranscript
- }
- tmpPath := tmpFile.Name()
- defer func() {
- if removeErr := os.Remove(tmpPath); removeErr != nil {
- logging.Debug(ctx, "failed to remove temporary summary transcript",
- slog.String("path", tmpPath),
- slog.String("error", removeErr.Error()))
- }
- }()
-
- if _, err := tmpFile.Write(scopedTranscript); err != nil {
- _ = tmpFile.Close()
- logging.Debug(ctx, "external summary compaction transcript write failed",
- slog.String("agent", string(agentType)),
- slog.String("error", err.Error()))
- return scopedTranscript
- }
- if err := tmpFile.Close(); err != nil {
- logging.Debug(ctx, "external summary compaction transcript close failed",
- slog.String("agent", string(agentType)),
- slog.String("error", err.Error()))
- return scopedTranscript
- }
-
- compacted, err := compactor.CompactTranscript(ctx, tmpPath)
- if err != nil || compacted == nil || len(compacted.Transcript) == 0 {
- if err != nil {
- logging.Debug(ctx, "external summary compaction failed",
- slog.String("agent", string(agentType)),
- slog.String("error", err.Error()))
- }
- return scopedTranscript
- }
-
- redacted, err := redact.JSONLBytes(compacted.Transcript)
- if err != nil {
- logging.Debug(ctx, "external summary compaction redaction failed",
- slog.String("agent", string(agentType)),
- slog.String("error", err.Error()))
- return scopedTranscript
- }
- redactedTranscript := redacted.Bytes()
- if !transcriptHasSummaryContent(redactedTranscript, agentType) {
- return scopedTranscript
- }
-
- logging.Debug(ctx, "using external compact transcript for summary generation",
- slog.String("agent", string(agentType)))
- return redactedTranscript
-}
-
-func transcriptHasSummaryContent(transcriptBytes []byte, agentType types.AgentType) bool {
- entries, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
- return err == nil && len(entries) > 0
-}
-
-// generateCheckpointAISummary returns the generated summary, the effective
-// deadline applied to the underlying call (which may be shorter than
-// checkpointSummaryTimeout if the parent context had an earlier deadline),
-// and any error. The effective deadline is returned so the caller can render
-// the true timeout value in user-facing error messages instead of always
-// showing the package default.
-func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, filesTouched []string, agentType types.AgentType, generator summarize.Generator) (*checkpoint.Summary, time.Duration, error) {
- timeoutCtx, cancel := context.WithTimeout(ctx, checkpointSummaryTimeout)
- timeoutDuration := checkpointSummaryTimeout
- if deadline, ok := timeoutCtx.Deadline(); ok {
- timeoutDuration = time.Until(deadline)
- }
- defer cancel()
-
- // scopedTranscript is either read from checkpoint storage (redacted on
- // write) or replaced by external compact output redacted before use.
- summary, err := generateTranscriptSummary(timeoutCtx, redact.AlreadyRedacted(scopedTranscript), filesTouched, agentType, generator)
- if err != nil {
- // Only classify as ctx cancel/deadline when the error chain actually
- // contains the sentinel. Relying on timeoutCtx.Err() here loses typed
- // errors (e.g. *ClaudeError) when the subprocess returned a real
- // structured failure while timeoutCtx.Err() is non-nil for any reason
- // (parent cancelled, deadline already elapsed, etc.).
- if errors.Is(err, context.Canceled) {
- return nil, timeoutDuration, fmt.Errorf("summary generation canceled: %w", err)
- }
- if errors.Is(err, context.DeadlineExceeded) {
- return nil, timeoutDuration, fmt.Errorf("summary generation timed out after %s: %w", formatSummaryTimeout(timeoutDuration), err)
- }
- return nil, timeoutDuration, err
- }
-
- return summary, timeoutDuration, nil
-}
-
-// formatCheckpointSummaryError maps typed Claude CLI errors and context
-// sentinels to a structured failure block: a user-visible label, supporting
-// rows, and a structured error suitable for wrapping in NewSilentError.
-//
-// The styled rendering happens in the caller (generateCheckpointSummary), which
-// renders to errW via newStatusStyles(...).renderFailure(label, rows). This
-// split keeps the formatting policy in one place (the failure block) while
-// letting the caller still return a *SilentError for main.go's exit handling.
-func formatCheckpointSummaryError(err error, deadline time.Duration) (string, []explainRow, error) {
- var claudeErr *claudecode.ClaudeError
- switch {
- case errors.As(err, &claudeErr):
- switch claudeErr.Kind { //nolint:exhaustive // ClaudeErrorUnknown handled by default
- case claudecode.ClaudeErrorAuth:
- label := "Claude authentication failed"
- rows := []explainRow{
- {Label: "try", Value: "run `claude login` and retry"},
- }
- if claudeErr.Message != "" {
- rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
- }
- //nolint:staticcheck // ST1005: Claude is a proper noun
- //lint:ignore ST1005 // Claude is a proper noun
- return label, rows, fmt.Errorf("Claude authentication failed%s", formatMessageSuffix(claudeErr.Message))
- case claudecode.ClaudeErrorRateLimit:
- label := "Claude rejected the summary request due to rate limits or quota"
- rows := []explainRow{
- {Label: "try", Value: "wait and retry"},
- }
- if claudeErr.Message != "" {
- rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
- }
- //nolint:staticcheck // ST1005: Claude is a proper noun
- //lint:ignore ST1005 // Claude is a proper noun
- return label, rows, fmt.Errorf("Claude rejected the summary request due to rate limits or quota%s", formatMessageSuffix(claudeErr.Message))
- case claudecode.ClaudeErrorConfig:
- label := "Claude rejected the summary request"
- rows := []explainRow{
- {Label: "try", Value: "check your Claude CLI config and selected model"},
- }
- if claudeErr.Message != "" {
- rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
- }
- //nolint:staticcheck // ST1005: Claude is a proper noun
- //lint:ignore ST1005 // Claude is a proper noun
- return label, rows, fmt.Errorf("Claude rejected the summary request%s", formatMessageSuffix(claudeErr.Message))
- case claudecode.ClaudeErrorCLIMissing:
- label := "Claude CLI is not installed or not on PATH"
- //nolint:staticcheck // ST1005: Claude is a proper noun
- //lint:ignore ST1005 // Claude is a proper noun
- return label, nil, errors.New("Claude CLI is not installed or not on PATH")
- default:
- label := "Claude failed to generate the summary"
- suffix := formatClaudeErrorSuffix(claudeErr)
- rows := []explainRow{
- {Label: "detail", Value: strings.TrimPrefix(strings.TrimPrefix(suffix, ": "), " ")},
- }
- //nolint:staticcheck // ST1005: Claude is a proper noun
- //lint:ignore ST1005 // Claude is a proper noun
- return label, rows, fmt.Errorf("Claude failed to generate the summary%s", suffix)
- }
- case errors.Is(err, context.DeadlineExceeded):
- // Deliberately provider-neutral: explain --generate supports multiple
- // summary providers (claude-code, codex, gemini, ...), so hardcoding
- // "Claude" / "sonnet" / "Anthropic" here would misdirect users who
- // selected a different provider in .trace/settings.json.
- label := "Summary generation timed out after " + formatSummaryTimeout(deadline)
- rows := []explainRow{
- {Label: "causes", Value: ""},
- {Label: "", Value: "• the selected model is taking longer than expected on a large transcript"},
- {Label: "", Value: "• the summary provider's CLI cannot reach its API (network, VPN, firewall)"},
- {Label: "", Value: "• the provider's API is degraded"},
- {Label: "try", Value: "run the provider CLI directly to confirm it works"},
- }
- return label, rows, fmt.Errorf("summary generation did not return within the %s safety deadline", formatSummaryTimeout(deadline))
- case errors.Is(err, context.Canceled):
- return "Summary generation canceled", nil, errors.New("summary generation canceled")
- default:
- return "Failed to generate summary", []explainRow{{Label: "detail", Value: err.Error()}}, fmt.Errorf("failed to generate summary: %w", err)
- }
-}
-
-// formatMessageSuffix formats ": " when msg is non-empty and "" otherwise.
-// Used by the Auth / RateLimit / Config branches of formatCheckpointSummaryError
-// to avoid rendering a bare colon when ClaudeError.Message is empty (reachable
-// when the CLI envelope is is_error:true with result:null but a real status).
-func formatMessageSuffix(msg string) string {
- if msg == "" {
- return ""
- }
- return ": " + msg
-}
-
-// formatClaudeErrorSuffix builds a diagnostic suffix for user-facing output
-// when we fall through to the default "failed to generate the summary" path.
-// Prefers the envelope Message, falls back to HTTP status, then exit code,
-// so the user never sees a bare "Claude failed to generate the summary:"
-// with nothing after the colon (which happens when Claude returns
-// is_error:true with result:null, or when the subprocess crashes with no
-// stderr output). ExitCode < 0 means the subprocess did not produce a real
-// exit code (e.g. launch failure) — render that as "abnormal termination"
-// rather than the misleading "exited with code -1".
-func formatClaudeErrorSuffix(e *claudecode.ClaudeError) string {
- if e.Message != "" {
- return ": " + e.Message
- }
- switch {
- case e.APIStatus != 0:
- return fmt.Sprintf(" (Anthropic API returned HTTP %d)", e.APIStatus)
- case e.ExitCode > 0:
- return fmt.Sprintf(" (claude CLI exited with code %d)", e.ExitCode)
- case e.ExitCode < 0:
- return " (claude CLI terminated abnormally — no exit code captured)"
- default:
- return " (no diagnostic detail available from Claude CLI)"
- }
-}
-
-func formatSummaryTimeout(d time.Duration) string {
- if d < 0 {
- d = 0
- }
- if d < time.Second {
- return d.Round(10 * time.Millisecond).String()
- }
- return d.Round(time.Second).String()
-}
-
-// explainTemporaryCheckpoint finds and formats a temporary checkpoint by shadow commit hash prefix.
-// Returns the formatted output, whether the checkpoint was found, and an
-// optional error. When err is non-nil, the function has already rendered a
-// styled failure block to errW; the caller should wrap and return as
-// SilentError without printing again.
-// Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints
-// created from different base commits (e.g., if HEAD advanced since session start).
-// The writer w is used for raw transcript output to bypass the pager.
-func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store *checkpoint.GitStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) {
- // List temporary checkpoints from ALL shadow branches
- // This ensures we find checkpoints even if HEAD has advanced since the session started
- tempCheckpoints, err := store.ListAllTemporaryCheckpoints(ctx, "", branchCheckpointsLimit)
- if err != nil {
- return "", false, nil //nolint:nilerr // best-effort: shadow-branch listing failure is reported as found=false; caller then falls back to ErrCheckpointNotFound with a user-facing hint instead of a raw git error
- }
-
- // Find checkpoints matching the SHA prefix - check for ambiguity
- var matches []checkpoint.TemporaryCheckpointInfo
- for _, tc := range tempCheckpoints {
- if strings.HasPrefix(tc.CommitHash.String(), shaPrefix) {
- matches = append(matches, tc)
- }
- }
-
- if len(matches) == 0 {
- return "", false, nil
- }
-
- if len(matches) > 1 {
- // Multiple matches: render styled failure block, return SilentError.
- ambiguous := make([]ambiguousMatch, 0, len(matches))
- for _, m := range matches {
- shortID := m.CommitHash.String()
- if len(shortID) > 7 {
- shortID = shortID[:7]
- }
- ambiguous = append(ambiguous, ambiguousMatch{
- ShortID: shortID,
- Timestamp: m.Timestamp,
- SessionID: m.SessionID,
- })
- }
- renderAmbiguousPrefixFailure(errW, shaPrefix, "temporary checkpoints", ambiguous)
- return "", false, NewSilentError(fmt.Errorf("%w: %s matches %d temporary checkpoints", errAmbiguousCommitPrefix, shaPrefix, len(matches)))
- }
-
- tc := matches[0]
-
- // Get shadow commit and tree to read metadata
- shadowCommit, commitErr := repo.CommitObject(tc.CommitHash)
- if commitErr != nil {
- return "", false, nil //nolint:nilerr // best-effort: shadow commit may have been GC'd or pruned; treat as not-found so the caller reports ErrCheckpointNotFound rather than an internal git error
- }
-
- shadowTree, treeErr := shadowCommit.Tree()
- if treeErr != nil {
- return "", false, nil //nolint:nilerr // best-effort: a shadow commit without a readable tree is corrupt/partial; treat as not-found so the caller reports ErrCheckpointNotFound rather than an internal git error
- }
-
- // Read agent type from shadow branch metadata (stored during checkpoint creation)
- agentType := strategy.ReadAgentTypeFromTree(shadowTree, tc.MetadataDir)
-
- // Handle raw transcript output
- if rawTranscript {
- transcriptBytes, transcriptErr := store.GetTranscriptFromCommit(ctx, tc.CommitHash, tc.MetadataDir, agentType)
- if transcriptErr != nil || len(transcriptBytes) == 0 {
- shortID := tc.CommitHash.String()[:7]
- return "", false, renderExplainFailure(errW, "Checkpoint has no transcript", []explainRow{
- {Label: "id", Value: shortID},
- }, fmt.Errorf("checkpoint %s has no transcript", shortID))
- }
- // Write directly to writer (no pager, no formatting) - matches committed checkpoint behavior
- if _, writeErr := fmt.Fprint(w, string(transcriptBytes)); writeErr != nil {
- return "", false, fmt.Errorf("failed to write transcript: %w", writeErr)
- }
- return "", true, nil
- }
-
- // Read prompts from shadow branch
- sessionPrompt := strategy.ReadSessionPromptFromTree(shadowTree, tc.MetadataDir)
-
- // Build output similar to formatCheckpointOutput but for temporary
- var sb strings.Builder
- shortID := tc.CommitHash.String()[:7]
- styles := newStatusStyles(w)
-
- label := fmt.Sprintf("Checkpoint %s [temporary]", shortID)
- rows := []explainRow{
- {Label: "session", Value: tc.SessionID},
- {Label: "created", Value: tc.Timestamp.Format("2006-01-02 15:04:05")},
- }
- sb.WriteString(styles.renderIdentity(label, "", rows))
-
- intent := extractIntent(nil, sessionPrompt)
- hint := "Not generated. Temporary checkpoints can be summarized after commit. Run `trace explain --generate` on the resulting commit."
- sb.WriteString(renderExplainBody(w, buildNoSummaryMarkdown(intent, nil, hint)))
-
- // Transcript section: full shows trace session, verbose shows checkpoint scope
- // For temporary checkpoints, load transcript and compute scope from parent commit
- var fullTranscript []byte
- var scopedTranscript []byte
- if full || verbose {
- fullTranscript, _ = store.GetTranscriptFromCommit(ctx, tc.CommitHash, tc.MetadataDir, agentType) //nolint:errcheck // Best-effort
-
- if verbose && len(fullTranscript) > 0 {
- // Compute scoped transcript by finding where parent's transcript ended
- // Each shadow branch commit has the full transcript up to that point,
- // so we diff against parent to get just this checkpoint's activity
- scopedTranscript = fullTranscript // Default to full if no parent
- if shadowCommit.NumParents() > 0 {
- if parent, parentErr := shadowCommit.Parent(0); parentErr == nil {
- parentTranscript, _ := store.GetTranscriptFromCommit(ctx, parent.Hash, tc.MetadataDir, agentType) //nolint:errcheck // Best-effort
- if len(parentTranscript) > 0 {
- parentOffset := transcriptOffset(parentTranscript, agentType)
- scopedTranscript = scopeTranscriptForCheckpoint(fullTranscript, parentOffset, agentType)
- }
- }
- }
- }
- }
- if verbose || full {
- label := "Transcript (checkpoint scope)"
- if full {
- label = "Transcript (full session)"
- }
- sb.WriteString("\n")
- sb.WriteString(styles.sectionRule(label, styles.width))
- sb.WriteString("\n")
- }
- appendTranscriptSection(&sb, verbose, full, fullTranscript, scopedTranscript, sessionPrompt, agentType)
-
- return sb.String(), true, nil
-}
-
-// getAssociatedCommits finds git commits that reference the given checkpoint ID.
-// Searches commits on the current branch for Trace-Checkpoint trailer matches.
-// When searchAll is true, uses full DAG walk with no depth limit (may be slow).
-// This finds checkpoint commits on merged feature branches (second parents of merges).
-func getAssociatedCommits(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID, searchAll bool) ([]associatedCommit, error) {
- head, err := repo.Head()
- if err != nil {
- return nil, fmt.Errorf("failed to get HEAD: %w", err)
- }
-
- commits := []associatedCommit{} // Initialize as empty slice, not nil (nil means "not searched")
- targetID := checkpointID.String()
-
- collectCommit := func(c *object.Commit) {
- fullSHA := c.Hash.String()
- shortSHA := fullSHA
- if len(fullSHA) >= 7 {
- shortSHA = fullSHA[:7]
- }
- commits = append(commits, associatedCommit{
- SHA: fullSHA,
- ShortSHA: shortSHA,
- Message: strings.Split(c.Message, "\n")[0],
- Author: c.Author.Name,
- Email: c.Author.Email,
- Date: c.Author.When,
- })
- }
-
- if searchAll {
- // Full DAG walk: follows all parents of merge commits, no depth limit.
- // This finds checkpoint commits on merged feature branches.
- iter, iterErr := repo.Log(&git.LogOptions{
- From: head.Hash(),
- Order: git.LogOrderCommitterTime,
- })
- if iterErr != nil {
- return nil, fmt.Errorf("failed to get commit log: %w", iterErr)
- }
- defer iter.Close()
-
- err = iter.ForEach(func(c *object.Commit) error {
- if err := ctx.Err(); err != nil {
- return err //nolint:wrapcheck // Propagating context cancellation
- }
- cpID, found := trailers.ParseCheckpoint(c.Message)
- if found && cpID.String() == targetID {
- collectCommit(c)
- }
- return nil
- })
- } else {
- // First-parent walk with depth limit and branch filtering.
- // Avoids walking into main's history through merge commit parents.
- reachableFromMain := computeReachableFromMain(ctx, repo)
-
- err = walkFirstParentCommits(ctx, repo, head.Hash(), commitScanLimit, func(c *object.Commit) error {
- // Once we hit a commit reachable from main on the first-parent chain,
- // all earlier ancestors are also shared-with-main, so stop scanning.
- if reachableFromMain[c.Hash] {
- return errStopIteration
- }
-
- cpID, found := trailers.ParseCheckpoint(c.Message)
- if found && cpID.String() == targetID {
- collectCommit(c)
- }
- return nil
- })
- }
-
- if err != nil {
- return nil, fmt.Errorf("error iterating commits: %w", err)
- }
-
- return commits, nil
-}
-
-// scopeTranscriptForCheckpoint slices a transcript to include only the portion
-// relevant to a specific checkpoint, starting from the given offset.
-// For Claude Code (JSONL), the offset is a line number and we slice by line.
-// For Gemini (single JSON blob), the offset is a message index and we slice by message.
-func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentType types.AgentType) []byte {
- switch agentType {
- case agent.AgentTypeGemini:
- scoped, err := geminicli.SliceFromMessage(fullTranscript, startOffset)
- if err != nil {
- return nil
- }
- return scoped
- case agent.AgentTypeOpenCode:
- scoped, err := opencode.SliceFromMessage(fullTranscript, startOffset)
- if err != nil {
- return nil
- }
- return scoped
- case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
- return transcript.SliceFromLine(fullTranscript, startOffset)
- }
- return transcript.SliceFromLine(fullTranscript, startOffset)
-}
-
-// extractPromptsFromTranscript extracts user prompts from transcript bytes.
-// Returns a slice of prompt strings.
-func extractPromptsFromTranscript(transcriptBytes []byte, agentType types.AgentType) []string {
- if len(transcriptBytes) == 0 {
- return nil
- }
-
- // transcriptBytes is read from checkpoint storage, which redacts on write.
- condensed, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
- if err != nil || len(condensed) == 0 {
- condensed, err = buildCondensedCompactTranscriptEntries(transcriptBytes)
- }
- if err != nil || len(condensed) == 0 {
- return nil
- }
-
- var prompts []string
- for _, entry := range condensed {
- if entry.Type == summarize.EntryTypeUser && entry.Content != "" {
- prompts = append(prompts, entry.Content)
- }
- }
- return prompts
-}
-
-// extractIntent picks the user-facing intent line from available prompt sources.
-// Preference: first non-empty entry of scopedPrompts, then first non-empty line
-// of fallbackPrompts, then "". Truncates to maxIntentDisplayLength.
-func extractIntent(scopedPrompts []string, fallbackPrompts string) string {
- for _, p := range scopedPrompts {
- if p == "" {
- continue
- }
- return strategy.TruncateDescription(p, maxIntentDisplayLength)
- }
- for _, line := range strings.Split(fallbackPrompts, "\n") {
- if line == "" {
- continue
- }
- return strategy.TruncateDescription(line, maxIntentDisplayLength)
- }
- return ""
-}
-
-// buildNoSummaryMarkdown renders the body for a checkpoint that does not yet
-// have an AI summary. It mirrors the `## Intent` / `## Summary` / `## Files`
-// shape of the generated case so the brand markdown renderer can take the same
-// path. The italic *summary* paragraph is the affordance pointing the user at
-// `--generate` (or, for temporary checkpoints, at committing first).
-func buildNoSummaryMarkdown(intent string, files []string, summaryHint string) string {
- var sb strings.Builder
-
- sb.WriteString("## Intent\n\n")
- if intent == "" {
- sb.WriteString("*(no prompt recorded)*\n\n")
- } else {
- fmt.Fprintf(&sb, "%s\n\n", escapeSummaryText(intent))
- }
-
- fmt.Fprintf(&sb, "## Summary\n\n*%s*\n", escapeSummaryText(summaryHint))
-
- if len(files) > 0 {
- fmt.Fprintf(&sb, "\n## Files (%d)\n\n", len(files))
- for _, f := range files {
- fmt.Fprintf(&sb, "- `%s`\n", escapeInlineCodeText(f))
- }
- }
-
- return sb.String()
-}
-
-// ambiguousMatch describes one match in an ambiguous-prefix failure.
-// SessionID is optional and only set for temporary-checkpoint matches.
-type ambiguousMatch struct {
- ShortID string
- Timestamp time.Time
- SessionID string
-}
-
-// renderAmbiguousPrefixFailure prints a styled failure block describing an
-// ambiguous prefix. kind is a noun phrase like "commits" or "temporary
-// checkpoints" used in the "matches N " header row.
-func renderAmbiguousPrefixFailure(errW io.Writer, prefix, kind string, matches []ambiguousMatch) {
- styles := newStatusStyles(errW)
- rows := []explainRow{
- {Label: "matches", Value: fmt.Sprintf("%d %s", len(matches), kind)},
- }
- for _, m := range matches {
- ts := ""
- if !m.Timestamp.IsZero() {
- ts = " " + m.Timestamp.Format("2006-01-02 15:04:05")
- }
- sess := ""
- if m.SessionID != "" {
- sess = " session " + m.SessionID
- }
- rows = append(rows, explainRow{Label: "", Value: "• " + m.ShortID + ts + sess})
- }
- rows = append(rows, explainRow{Label: "hint", Value: "use a longer prefix or a full SHA"})
- label := fmt.Sprintf("Ambiguous checkpoint prefix %q", prefix)
- fmt.Fprint(errW, styles.renderFailure(label, rows))
-}
-
-// renderExplainFailure prints a styled failure block to errW and returns the
-// error wrapped as *SilentError so main.go does not double-print. Used at
-// every explain call site that has a friendly, structured error to surface.
-func renderExplainFailure(errW io.Writer, label string, rows []explainRow, structured error) error {
- fmt.Fprint(errW, newStatusStyles(errW).renderFailure(label, rows))
- return NewSilentError(structured)
-}
-
-// buildAmbiguousCommitMatches converts a slice of plumbing.Hash matches
-// (from resolveCommitUnambiguous) into ambiguousMatch entries with
-// abbreviated short IDs and author timestamps. Caps at 5 entries to keep
-// the failure block readable when a short prefix collides on many
-// commits.
-func buildAmbiguousCommitMatches(repo *git.Repository, hashes []plumbing.Hash) []ambiguousMatch {
- const maxMatches = 5
- matches := make([]ambiguousMatch, 0, len(hashes))
- for i, h := range hashes {
- if i >= maxMatches {
- break
- }
- m := ambiguousMatch{ShortID: abbreviateCommitHash(repo, h)}
- if commit, err := repo.CommitObject(h); err == nil {
- m.Timestamp = commit.Author.When
- }
- matches = append(matches, m)
- }
- return matches
-}
-
-// buildAmbiguousCheckpointMatches converts a slice of CheckpointID matches
-// into ambiguousMatch entries enriched with timestamps and session IDs from
-// the loaded committed-checkpoint listing. Caps at 5 entries to keep the
-// failure block readable when a short prefix collides on many checkpoints.
-func buildAmbiguousCheckpointMatches(ids []id.CheckpointID, committed []checkpoint.CommittedInfo) []ambiguousMatch {
- const maxMatches = 5
- infoByID := make(map[id.CheckpointID]checkpoint.CommittedInfo, len(committed))
- for _, info := range committed {
- infoByID[info.CheckpointID] = info
- }
- matches := make([]ambiguousMatch, 0, len(ids))
- for i, cpID := range ids {
- if i >= maxMatches {
- break
- }
- m := ambiguousMatch{ShortID: cpID.String()}
- if info, ok := infoByID[cpID]; ok {
- m.Timestamp = info.CreatedAt
- m.SessionID = info.SessionID
- }
- matches = append(matches, m)
- }
- return matches
-}
-
-// renderExplainBody routes a markdown body through the brand renderer when
-// the writer supports color, and returns the markdown source verbatim
-// otherwise. Single point of policy for every explain body section.
-func renderExplainBody(w io.Writer, md string) string {
- if !shouldUseColor(w) {
- return md
- }
- rendered, err := defaultRenderTerminalMarkdown(w, md)
- if err != nil {
- logging.Debug(context.Background(), "explain markdown render failed", slog.String("error", err.Error()))
- return md
- }
- return rendered
-}
-
-// formatCheckpointOutput formats checkpoint data based on verbosity level.
-// When verbose is false: summary only (ID, session, timestamp, tokens, intent).
-// When verbose is true: adds files, associated commits, and scoped transcript for this checkpoint.
-// When full is true: shows parsed full session transcript instead of scoped transcript.
-//
-// Transcript scope is controlled by CheckpointTranscriptStart in metadata, which indicates
-// where this checkpoint's content begins in the full session transcript.
-//
-// Author is displayed when available (only for committed checkpoints).
-// Associated commits are git commits that reference this checkpoint via Trace-Checkpoint trailer.
-func formatCheckpointOutput(summary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, checkpointID id.CheckpointID, associatedCommits []associatedCommit, author checkpoint.Author, verbose, full bool, w io.Writer) string {
- var sb strings.Builder
- meta := content.Metadata
- styles := newStatusStyles(w)
-
- // Scope the transcript to this checkpoint's portion
- // If CheckpointTranscriptStart > 0, we slice the transcript to only include
- // content from that point onwards (excluding earlier checkpoint content)
- scopedTranscript := scopeTranscriptForCheckpoint(content.Transcript, meta.GetTranscriptStart(), meta.Agent)
-
- // Extract prompts from the scoped transcript for intent extraction
- scopedPrompts := extractPromptsFromTranscript(scopedTranscript, meta.Agent)
-
- sb.WriteString(formatCheckpointHeader(summary, meta, checkpointID, associatedCommits, author, styles))
- sb.WriteString(styles.horizontalRule(styles.width))
- sb.WriteString("\n")
-
- if meta.Summary != nil {
- md := buildSummaryMarkdown(meta.Summary)
- if verbose || full {
- md += buildFilesMarkdown(meta.FilesTouched)
- }
- if shouldUseColor(w) {
- rendered, err := defaultRenderTerminalMarkdown(w, md)
- if err != nil {
- logging.Debug(context.Background(), "explain markdown render failed", slog.String("error", err.Error()))
- sb.WriteString(md)
- } else {
- sb.WriteString(rendered)
- }
- } else {
- sb.WriteString(md)
- }
- } else {
- intent := extractIntent(scopedPrompts, content.Prompts)
-
- var files []string
- if verbose || full {
- files = meta.FilesTouched
- }
-
- hint := fmt.Sprintf("Not generated yet. Run `trace explain --generate %s` to create an AI summary.", checkpointID)
- md := buildNoSummaryMarkdown(intent, files, hint)
- sb.WriteString(renderExplainBody(w, md))
- }
-
- if verbose || full {
- label := "Transcript (checkpoint scope)"
- if full {
- label = "Transcript (full session)"
- }
- sb.WriteString("\n")
- sb.WriteString(styles.sectionRule(label, styles.width))
- sb.WriteString("\n")
- appendTranscriptSection(&sb, verbose, full, content.Transcript, scopedTranscript, content.Prompts, meta.Agent)
- }
-
- return sb.String()
-}
-
-// appendTranscriptSection appends the appropriate transcript section to the builder
-// based on verbosity level. Full mode shows the trace session, verbose shows checkpoint scope.
-// fullTranscript is the trace session transcript, scopedContent is either scoped transcript bytes
-// or a pre-formatted string (for backwards compat), and scopedFallback is used when scoped parsing fails.
-func appendTranscriptSection(sb *strings.Builder, verbose, full bool, fullTranscript, scopedTranscript []byte, scopedFallback string, agentType types.AgentType) {
- switch {
- case full:
- sb.WriteString(formatTranscriptBytes(fullTranscript, "", agentType))
-
- case verbose:
- sb.WriteString(formatTranscriptBytes(scopedTranscript, scopedFallback, agentType))
- }
-}
-
-// formatTranscriptBytes formats transcript bytes into a human-readable string.
-// It parses the transcript (JSONL for Claude, JSON for Gemini) and formats it using the condensed format.
-// The fallback is used for backwards compatibility when transcript parsing fails or is empty.
-func formatTranscriptBytes(transcriptBytes []byte, fallback string, agentType types.AgentType) string {
- if len(transcriptBytes) == 0 {
- if fallback != "" {
- return fallback + "\n"
- }
- return " (none)\n"
- }
-
- // transcriptBytes is read from checkpoint storage, which redacts on write.
- condensed, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
- if err != nil || len(condensed) == 0 {
- condensed, err = buildCondensedCompactTranscriptEntries(transcriptBytes)
- }
- if err != nil || len(condensed) == 0 {
- if fallback != "" {
- return fallback + "\n"
- }
- return " (failed to parse transcript)\n"
- }
-
- input := summarize.Input{Transcript: condensed}
- return summarize.FormatCondensedTranscript(input)
-}
-
-func buildCondensedCompactTranscriptEntries(transcriptBytes []byte) ([]summarize.Entry, error) {
- compactEntries, err := transcriptcompact.BuildCondensedEntries(transcriptBytes)
- if err != nil {
- return nil, fmt.Errorf("parsing compact transcript: %w", err)
- }
-
- entries := make([]summarize.Entry, 0, len(compactEntries))
- for _, entry := range compactEntries {
- switch entry.Type {
- case "user":
- entries = append(entries, summarize.Entry{Type: summarize.EntryTypeUser, Content: entry.Content})
- case "assistant":
- entries = append(entries, summarize.Entry{Type: summarize.EntryTypeAssistant, Content: entry.Content})
- case "tool": //nolint:goconst // semantic label, not worth a constant
- entries = append(entries, summarize.Entry{Type: summarize.EntryTypeTool, ToolName: entry.ToolName, ToolDetail: entry.ToolDetail})
- }
- }
-
- if len(entries) == 0 {
- return nil, errors.New("no parseable compact transcript entries")
- }
-
- return entries, nil
-}
-
-// formatCheckpointHeader builds the metadata block above the summary body.
-// When color is enabled, values are styled with the shared status palette;
-// otherwise the same compact shape is returned as plain text.
-func formatCheckpointHeader(
- summary *checkpoint.CheckpointSummary,
- meta checkpoint.CommittedMetadata,
- cpID id.CheckpointID,
- commits []associatedCommit,
- author checkpoint.Author,
- styles statusStyles,
-) string {
- var sb strings.Builder
-
- headline := "● Checkpoint " + cpID.String()
- if styles.colorEnabled {
- bullet := styles.render(lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")), "●")
- key := styles.render(styles.bold, "Checkpoint")
- val := styles.render(lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")), cpID.String())
- headline = bullet + " " + key + " " + val
- }
- sb.WriteString(headline)
- sb.WriteString("\n")
-
- writeRow := func(label, value string) {
- paddedLabel := fmt.Sprintf("%-9s", label)
- if styles.colorEnabled {
- paddedLabel = styles.render(styles.dim, paddedLabel)
- }
- fmt.Fprintf(&sb, " %s%s\n", paddedLabel, value)
- }
-
- writeRow("session", meta.SessionID)
- writeRow("created", meta.CreatedAt.Format("2006-01-02 15:04:05"))
- if author.Name != "" {
- writeRow("author", fmt.Sprintf("%s <%s>", author.Name, author.Email))
- }
-
- tokenUsage := meta.TokenUsage
- if tokenUsage == nil && summary != nil {
- tokenUsage = summary.TokenUsage
- }
- if tokenUsage != nil {
- total := tokenUsage.InputTokens + tokenUsage.CacheCreationTokens +
- tokenUsage.CacheReadTokens + tokenUsage.OutputTokens
- tokensVal := formatTokenCount(total)
- if styles.colorEnabled {
- tokensVal = styles.render(styles.yellow, tokensVal)
- }
- writeRow("tokens", tokensVal)
- }
-
- switch {
- case commits == nil:
- case len(commits) == 0:
- writeRow("commits", "(none on this branch)")
- case len(commits) == 1:
- c := commits[0]
- writeRow("commits", fmt.Sprintf("%s %s", c.ShortSHA, c.Message))
- default:
- writeRow("commits", fmt.Sprintf("(%d)", len(commits)))
- for _, c := range commits {
- fmt.Fprintf(&sb, " %s %s %s\n",
- c.ShortSHA, c.Date.Format("2006-01-02"), c.Message)
- }
- }
-
- return sb.String()
-}
-
-// buildFilesMarkdown renders touched files as a markdown block for verbose
-// and full output when an AI summary is present.
-func buildFilesMarkdown(files []string) string {
- if len(files) == 0 {
- return "\n## Files\n\n*(none)*\n"
- }
- var sb strings.Builder
- sb.WriteString("\n## Files\n\n")
- for _, f := range files {
- fmt.Fprintf(&sb, "- `%s`\n", escapeInlineCodeText(f))
- }
- return sb.String()
-}
-
-// buildSummaryMarkdown renders a checkpoint AI summary into the brand
-// markdown shape used by entire's TTY renderer. The output is also the
-// source of truth for non-TTY callers, which write it verbatim.
-func buildSummaryMarkdown(s *checkpoint.Summary) string {
- if s == nil {
- return ""
- }
- var sb strings.Builder
-
- fmt.Fprintf(&sb, "## Intent\n\n%s\n\n", escapeSummaryText(s.Intent))
- fmt.Fprintf(&sb, "## Outcome\n\n%s\n\n", escapeSummaryText(s.Outcome))
-
- if hasAnyLearning(s.Learnings) {
- sb.WriteString("## Learnings\n\n")
- if len(s.Learnings.Repo) > 0 {
- sb.WriteString("### Repository\n\n")
- for _, item := range s.Learnings.Repo {
- fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
- }
- sb.WriteString("\n")
- }
- if len(s.Learnings.Code) > 0 {
- sb.WriteString("### Code\n\n")
- for _, item := range s.Learnings.Code {
- fmt.Fprintf(&sb, "- %s\n", formatCodeLearning(item))
- }
- sb.WriteString("\n")
- }
- if len(s.Learnings.Workflow) > 0 {
- sb.WriteString("### Workflow\n\n")
- for _, item := range s.Learnings.Workflow {
- fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
- }
- sb.WriteString("\n")
- }
- }
-
- if len(s.Friction) > 0 {
- sb.WriteString("## Friction\n\n")
- for _, item := range s.Friction {
- fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
- }
- sb.WriteString("\n")
- }
-
- if len(s.OpenItems) > 0 {
- sb.WriteString("## Open Items\n\n")
- for _, item := range s.OpenItems {
- fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
- }
- sb.WriteString("\n")
- }
-
- return strings.TrimRight(sb.String(), "\n") + "\n"
-}
-
-func hasAnyLearning(l checkpoint.LearningsSummary) bool {
- return len(l.Repo) > 0 || len(l.Code) > 0 || len(l.Workflow) > 0
-}
-
-func formatCodeLearning(c checkpoint.CodeLearning) string {
- path := escapeSummaryText(c.Path)
- finding := escapeSummaryText(c.Finding)
- switch {
- case c.Line > 0 && c.EndLine > 0:
- return fmt.Sprintf("`%s:%d-%d` — %s", path, c.Line, c.EndLine, finding)
- case c.Line > 0:
- return fmt.Sprintf("`%s:%d` — %s", path, c.Line, finding)
- default:
- return fmt.Sprintf("`%s` — %s", path, finding)
- }
-}
-
-func escapeSummaryText(s string) string {
- return strings.ReplaceAll(strings.TrimSpace(s), "`", "‘")
-}
-
-func escapeInlineCodeText(s string) string {
- s = strings.ReplaceAll(s, "\r\n", " ")
- s = strings.ReplaceAll(s, "\r", " ")
- s = strings.ReplaceAll(s, "\n", " ")
- return strings.ReplaceAll(s, "`", "‘")
-}
-
-// runExplainDefault shows all checkpoints on the current branch.
-// This is the default view when no flags are provided.
-func runExplainDefault(ctx context.Context, w io.Writer, noPager bool) error {
- return runExplainBranchDefault(ctx, w, noPager)
-}
-
-// branchCheckpointsLimit is the max checkpoints to show in branch view
-const branchCheckpointsLimit = 100
-
-// commitScanLimit is how far back to scan git history for checkpoints
-const commitScanLimit = 500
-
-// errStopIteration is used to stop commit iteration early
-var errStopIteration = errors.New("stop iteration")
-
-// getCurrentWorktreeHash returns the hashed worktree ID for the current working directory.
-// This is used to filter shadow branches to only those belonging to this worktree.
-func getCurrentWorktreeHash(ctx context.Context) string {
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return ""
- }
- worktreeID, err := paths.GetWorktreeID(repoRoot)
- if err != nil {
- return ""
- }
- return checkpoint.HashWorktreeID(worktreeID)
-}
-
-// computeReachableFromMain returns a set of commit hashes on the main/default branch's first-parent chain.
-// On the default branch itself, returns an empty map (no filtering needed).
-// Only first-parent commits are included — commits from side branches merged into main are excluded,
-// since those could be feature branch commits that shouldn't be filtered out.
-func computeReachableFromMain(ctx context.Context, repo *git.Repository) map[plumbing.Hash]bool {
- reachableFromMain := make(map[plumbing.Hash]bool)
-
- isOnDefault, _ := strategy.IsOnDefaultBranch(repo)
- if isOnDefault {
- return reachableFromMain // No filtering needed on default branch
- }
-
- // Resolve main branch hash
- var mainBranchHash plumbing.Hash
- if defaultBranchName := strategy.GetDefaultBranchName(repo); defaultBranchName != "" {
- ref, refErr := repo.Reference(plumbing.ReferenceName("refs/heads/"+defaultBranchName), true)
- if refErr != nil {
- ref, refErr = repo.Reference(plumbing.ReferenceName("refs/remotes/origin/"+defaultBranchName), true)
- }
- if refErr == nil {
- mainBranchHash = ref.Hash()
- }
- }
- if mainBranchHash == plumbing.ZeroHash {
- mainBranchHash = strategy.GetMainBranchHash(repo)
- }
- if mainBranchHash == plumbing.ZeroHash {
- return reachableFromMain
- }
-
- // Walk main's first-parent chain to build the set
- _ = walkFirstParentCommits(ctx, repo, mainBranchHash, strategy.MaxCommitTraversalDepth, func(c *object.Commit) error { //nolint:errcheck // Best-effort
- reachableFromMain[c.Hash] = true
- return nil
- })
-
- return reachableFromMain
-}
-
-// walkFirstParentCommits walks the first-parent chain starting from `from`,
-// calling fn for each commit. It stops after visiting `limit` commits (0 = no limit).
-// This avoids the full DAG traversal that repo.Log() does, which follows ALL parents
-// of merge commits and can walk into unrelated branch history (e.g., main's full
-// history after merging main into a feature branch).
-func walkFirstParentCommits(ctx context.Context, repo *git.Repository, from plumbing.Hash, limit int, fn func(*object.Commit) error) error {
- current, err := repo.CommitObject(from)
- if err != nil {
- return fmt.Errorf("failed to get commit %s: %w", from, err)
- }
-
- for count := 0; limit <= 0 || count < limit; count++ {
- if err := ctx.Err(); err != nil {
- return err //nolint:wrapcheck // Propagating context cancellation
- }
- if err := fn(current); err != nil {
- if errors.Is(err, errStopIteration) {
- return nil
- }
- return err
- }
-
- // Follow first parent only (skip merge parents).
- // When there are no parents or parent lookup fails, we've reached the
- // end of the chain — this is a normal termination, not an error.
- if current.NumParents() == 0 {
- return nil
- }
- parentHash := current.Hash
- current, err = current.Parent(0)
- if err != nil {
- return fmt.Errorf("failed to load first parent of commit %s: %w", parentHash, err)
- }
- }
- return nil
-}
-
-// getBranchCheckpoints returns checkpoints relevant to the current branch.
-// This is strategy-agnostic - it queries checkpoints directly from the checkpoint store.
-//
-// Behavior:
-// - On feature branches: only show checkpoints unique to this branch (not in main)
-// - On default branch (main/master): show all checkpoints in history (up to limit)
-// - Includes both committed checkpoints (trace/checkpoints/v1) and temporary checkpoints (shadow branches)
-func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) ([]strategy.RewindPoint, error) {
- // Warn (once per process) if metadata branches are disconnected
- strategy.WarnIfMetadataDisconnected()
-
- v1Store := checkpoint.NewGitStore(repo)
- v2URL, err := remote.FetchURL(ctx)
- if err != nil {
- logging.Debug(
- ctx, "explain: using origin for branch checkpoint v2 store fetch remote",
- slog.String("error", err.Error()),
- )
- v2URL = ""
- }
- v2Store := checkpoint.NewV2GitStore(repo, v2URL)
- preferCheckpointsV2 := settings.IsCheckpointsV2Enabled(ctx)
-
- // Get all committed checkpoints for lookup (v2-aware with v1 fallback).
- committedInfos, err := listCommittedForExplain(ctx, v1Store, v2Store, preferCheckpointsV2)
- if err != nil {
- committedInfos = nil // Continue without committed checkpoints
- }
-
- // Build map of checkpoint ID -> committed info
- committedByID := make(map[id.CheckpointID]checkpoint.CommittedInfo)
- for _, info := range committedInfos {
- if !info.CheckpointID.IsEmpty() {
- committedByID[info.CheckpointID] = info
- }
- }
-
- head, err := repo.Head()
- if err != nil {
- // Unborn HEAD (no commits yet) - return empty list instead of erroring
- if errors.Is(err, plumbing.ErrReferenceNotFound) {
- return []strategy.RewindPoint{}, nil
- }
- return nil, fmt.Errorf("failed to get HEAD: %w", err)
- }
-
- // Check if we're on the default branch (needed for getReachableTemporaryCheckpoints)
- isOnDefault, _ := strategy.IsOnDefaultBranch(repo)
-
- // Fetch metadata trees for reading session prompts (cheap tree lookups).
- // Try v2 /main first, fall back to v1 metadata branch.
- v1MetadataTree, _ := strategy.GetMetadataBranchTree(repo) //nolint:errcheck // Best-effort
- v2MetadataTree, _ := strategy.GetV2MetadataBranchTree(repo) //nolint:errcheck // Best-effort
- promptTree := resolvePromptTree(v1MetadataTree, v2MetadataTree, preferCheckpointsV2)
-
- var points []strategy.RewindPoint
-
- collectCheckpoint := func(c *object.Commit) {
- cpID, found := trailers.ParseCheckpoint(c.Message)
- if !found {
- return
- }
- cpInfo, found := committedByID[cpID]
- if !found {
- return
- }
-
- message := strings.Split(c.Message, "\n")[0]
- point := strategy.RewindPoint{
- ID: c.Hash.String(),
- Message: message,
- Date: c.Committer.When,
- IsLogsOnly: true, // Committed checkpoints are logs-only
- CheckpointID: cpID,
- SessionID: cpInfo.SessionID,
- IsTaskCheckpoint: cpInfo.IsTask,
- ToolUseID: cpInfo.ToolUseID,
- Agent: cpInfo.Agent,
- }
- // Read session prompt from metadata tree (best-effort).
- // Read prompt.txt directly from the latest session subdirectory instead of
- // parsing the full transcript — prompt.txt is tiny vs multi-MB transcripts.
- if promptTree != nil {
- point.SessionPrompt = strategy.ReadLatestSessionPromptFromCommittedTree(promptTree, cpID, cpInfo.SessionCount)
- }
-
- points = append(points, point)
- }
-
- if isOnDefault {
- // On the default branch, use full DAG walk to find checkpoint commits
- // on merged feature branches (second parents of merge commits).
- iter, iterErr := repo.Log(&git.LogOptions{
- From: head.Hash(),
- Order: git.LogOrderCommitterTime,
- })
- if iterErr != nil {
- return nil, fmt.Errorf("failed to get commit log: %w", iterErr)
- }
- defer iter.Close()
-
- count := 0
- err = iter.ForEach(func(c *object.Commit) error {
- if err := ctx.Err(); err != nil {
- return err //nolint:wrapcheck // Propagating context cancellation
- }
- if count >= commitScanLimit {
- return storer.ErrStop
- }
- count++
- collectCheckpoint(c)
- return nil
- })
- } else {
- // On feature branches, use first-parent walk with branch filtering.
- // This avoids walking into main's full history through merge commit parents.
- reachableFromMain := computeReachableFromMain(ctx, repo)
-
- err = walkFirstParentCommits(ctx, repo, head.Hash(), commitScanLimit, func(c *object.Commit) error {
- // Once we hit a commit reachable from main on the first-parent chain,
- // all earlier ancestors are also shared-with-main, so stop scanning.
- if reachableFromMain[c.Hash] {
- return errStopIteration
- }
- collectCheckpoint(c)
- return nil
- })
- }
-
- if err != nil {
- return nil, fmt.Errorf("error iterating commits: %w", err)
- }
-
- // Get temporary checkpoints from ALL shadow branches whose base commit is reachable from HEAD.
- tempPoints := getReachableTemporaryCheckpoints(ctx, repo, v1Store, head.Hash(), isOnDefault, limit)
- points = append(points, tempPoints...)
-
- // Sort by date, most recent first
- sort.Slice(points, func(i, j int) bool {
- return points[i].Date.After(points[j].Date)
- })
-
- // Apply limit
- if len(points) > limit {
- points = points[:limit]
- }
-
- return points, nil
-}
-
-// getReachableTemporaryCheckpoints returns temporary checkpoints from shadow branches
-// whose base commit is reachable from the given HEAD hash and that belong to this worktree.
-// For default branches, all shadow branches for this worktree are included.
-// For feature branches, only shadow branches whose base commit is in HEAD's history are included.
-func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store *checkpoint.GitStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint {
- var points []strategy.RewindPoint
-
- // Compute current worktree's hash for filtering shadow branches
- currentWorktreeHash := getCurrentWorktreeHash(ctx)
-
- shadowBranches, _ := store.ListTemporary(ctx) //nolint:errcheck // Best-effort
- for _, sb := range shadowBranches {
- // Filter by worktree: only show shadow branches belonging to this worktree.
- // Skip filtering if currentWorktreeHash is empty (error computing it) to avoid
- // accidentally filtering out ALL shadow branches.
- _, branchWorktreeHash, parsed := checkpoint.ParseShadowBranchName(sb.BranchName)
- if currentWorktreeHash != "" && parsed && branchWorktreeHash != "" && branchWorktreeHash != currentWorktreeHash {
- continue
- }
-
- // Check if this shadow branch's base commit is reachable from current HEAD
- if !isShadowBranchReachable(ctx, repo, sb.BaseCommit, headHash, isOnDefault) {
- continue
- }
-
- // List checkpoints from this shadow branch
- tempCheckpoints, _ := store.ListCheckpointsForBranch(ctx, sb.BranchName, "", limit) //nolint:errcheck // Best-effort
- for _, tc := range tempCheckpoints {
- point := convertTemporaryCheckpoint(repo, tc)
- if point != nil {
- points = append(points, *point)
- }
- }
- }
-
- return points
-}
-
-// isShadowBranchReachable checks if a shadow branch's base commit is reachable from HEAD.
-// For default branches, all shadow branches are considered reachable.
-// For feature branches, we check if any commit with the base commit prefix is in HEAD's history.
-func isShadowBranchReachable(ctx context.Context, repo *git.Repository, baseCommit string, headHash plumbing.Hash, isOnDefault bool) bool {
- // For default branch: all shadow branches are potentially relevant
- if isOnDefault {
- return true
- }
-
- // Check if base commit hash prefix matches any commit in HEAD's first-parent chain
- found := false
- _ = walkFirstParentCommits(ctx, repo, headHash, commitScanLimit, func(c *object.Commit) error { //nolint:errcheck // Best-effort
- if strings.HasPrefix(c.Hash.String(), baseCommit) {
- found = true
- return errStopIteration
- }
- return nil
- })
-
- return found
-}
-
-// convertTemporaryCheckpoint converts a TemporaryCheckpointInfo to a RewindPoint.
-// Returns nil if the checkpoint should be skipped (no tree changes or can't be read).
-//
-// Filtering uses hasAnyChanges (O(1) tree hash comparison) rather than hasCodeChanges
-// (O(files) full diff). This means metadata-only checkpoints (.trace/ changes without
-// code changes) are kept — only true no-ops (identical tree as parent) are dropped.
-// This trade-off is intentional for list-view performance.
-func convertTemporaryCheckpoint(repo *git.Repository, tc checkpoint.TemporaryCheckpointInfo) *strategy.RewindPoint {
- shadowCommit, commitErr := repo.CommitObject(tc.CommitHash)
- if commitErr != nil {
- return nil
- }
-
- // Skip no-op commits where the tree is identical to the parent's.
- // Note: this keeps metadata-only changes (e.g. transcript updates in .trace/)
- // since those produce a different tree hash. See hasAnyChanges godoc.
- if !hasAnyChanges(shadowCommit) {
- return nil
- }
-
- // Read session prompt from the shadow branch commit's tree (not from trace/checkpoints/v1)
- // Temporary checkpoints store their metadata in the shadow branch, not in trace/checkpoints/v1
- var sessionPrompt string
- shadowTree, treeErr := shadowCommit.Tree()
- if treeErr == nil {
- sessionPrompt = strategy.ReadSessionPromptFromTree(shadowTree, tc.MetadataDir)
- }
-
- return &strategy.RewindPoint{
- ID: tc.CommitHash.String(),
- Message: tc.Message,
- MetadataDir: tc.MetadataDir,
- Date: tc.Timestamp,
- IsTaskCheckpoint: tc.IsTaskCheckpoint,
- ToolUseID: tc.ToolUseID,
- SessionID: tc.SessionID,
- SessionPrompt: sessionPrompt,
- IsLogsOnly: false, // Temporary checkpoints can be fully rewound
- }
-}
-
-// runExplainBranchWithFilter shows checkpoints on the current branch, optionally filtered by session.
-// This is strategy-agnostic - it queries checkpoints directly.
-func runExplainBranchWithFilter(ctx context.Context, w io.Writer, noPager bool, sessionFilter string) error {
- repo, err := openRepository(ctx)
- if err != nil {
- return fmt.Errorf("not a git repository: %w", err)
- }
-
- // Get current branch name
- branchName := strategy.GetCurrentBranchName(repo)
- if branchName == "" {
- // Detached HEAD state or unborn HEAD - try to use short commit hash if possible
- head, headErr := repo.Head()
- if headErr != nil {
- // Unborn HEAD (no commits yet) - treat as empty history instead of erroring
- if errors.Is(headErr, plumbing.ErrReferenceNotFound) {
- branchName = "HEAD (no commits yet)"
- } else {
- return fmt.Errorf("failed to get HEAD: %w", headErr)
- }
- } else {
- branchName = "HEAD (" + head.Hash().String()[:7] + ")"
- }
- }
-
- // Get checkpoints for this branch (strategy-agnostic)
- points, err := getBranchCheckpoints(ctx, repo, branchCheckpointsLimit)
- if err != nil {
- // If context was cancelled (e.g. user hit Ctrl+C), exit silently
- if ctx.Err() != nil {
- return NewSilentError(ctx.Err())
- }
- // Log the error but continue with empty list so user sees helpful message
- logging.Warn(ctx, "failed to get branch checkpoints", "error", err)
- points = nil
- }
-
- // Format output
- output := formatBranchCheckpoints(w, branchName, points, sessionFilter)
-
- outputExplainContent(w, output, noPager)
- return nil
-}
-
-// runExplainBranchDefault shows all checkpoints on the current branch grouped by date.
-// This is a convenience wrapper that calls runExplainBranchWithFilter with no filter.
-func runExplainBranchDefault(ctx context.Context, w io.Writer, noPager bool) error {
- return runExplainBranchWithFilter(ctx, w, noPager, "")
-}
-
-// outputExplainContent outputs content with optional pager support.
-func outputExplainContent(w io.Writer, content string, noPager bool) {
- if noPager {
- fmt.Fprint(w, content)
- } else {
- outputWithPager(w, content)
- }
-}
-
-// runExplainCommit looks up the checkpoint associated with a commit.
-// Extracts the Trace-Checkpoint trailer and delegates to checkpoint detail view.
-// If no trailer found, shows a message indicating no associated checkpoint.
-func runExplainCommit(ctx context.Context, w, errW io.Writer, commitRef string, noPager, verbose, full, rawTranscript, generate, force, searchAll bool) error {
- repo, err := openRepository(ctx)
- if err != nil {
- return fmt.Errorf("not a git repository: %w", err)
- }
-
- // Resolve the commit reference, erroring on hex-prefix ambiguity
- // instead of silently picking the first matching commit.
- hash, ambiguousMatches, err := resolveCommitUnambiguous(repo, commitRef)
- if err != nil {
- if errors.Is(err, errAmbiguousCommitPrefix) {
- renderAmbiguousPrefixFailure(errW, commitRef, "commits", buildAmbiguousCommitMatches(repo, ambiguousMatches))
- return NewSilentError(err)
- }
- return renderExplainFailure(errW, "Commit not found", []explainRow{
- {Label: "ref", Value: commitRef},
- }, fmt.Errorf("commit not found: %s", commitRef))
- }
-
- commit, err := repo.CommitObject(hash)
- if err != nil {
- return fmt.Errorf("failed to get commit: %w", err)
- }
-
- // Extract Trace-Checkpoint trailer
- checkpointID, hasCheckpoint := trailers.ParseCheckpoint(commit.Message)
- if !hasCheckpoint {
- // Side-effect modes must error so scripts can distinguish "done"
- // from "didn't happen"; read-only modes print a friendly message.
- if generate || rawTranscript {
- return fmt.Errorf("cannot %s: commit %s has no Trace-Checkpoint trailer", generateOrRawLabel(generate), abbreviateCommitHash(repo, hash))
- }
- printNoTrailerMessage(w, repo, hash)
- return nil
- }
-
- // Delegate to checkpoint detail view, forwarding the full flag set so
- // --generate / --raw-transcript / --force work via --commit as well.
- return runExplainCheckpoint(ctx, w, errW, checkpointID.String(), noPager, verbose, full, rawTranscript, generate, force, searchAll)
-}
-
-// formatSessionInfo formats session information for display.
-//
-// NOTE: This function has no production caller — `trace explain --session`
-// flows through formatBranchCheckpoints (the list view filtered by session),
-// not through here. It is kept for tests that exercise the per-checkpoint
-// markdown body shape used elsewhere; restyling it for the brand format was
-// not worth the diff. If the CLI ever grows a session-detail surface, revisit.
-func formatSessionInfo(session *strategy.Session, sourceRef string, checkpoints []checkpointDetail) string {
- var sb strings.Builder
-
- // Session header
- fmt.Fprintf(&sb, "Session: %s\n", session.ID)
- fmt.Fprintf(&sb, "Strategy: %s\n", session.Strategy)
-
- if !session.StartTime.IsZero() {
- fmt.Fprintf(&sb, "Started: %s\n", session.StartTime.Format("2006-01-02 15:04:05"))
- }
-
- if sourceRef != "" {
- fmt.Fprintf(&sb, "Source Ref: %s\n", sourceRef)
- }
-
- fmt.Fprintf(&sb, "Checkpoints: %d\n", len(checkpoints))
-
- // Checkpoint details
- for _, cp := range checkpoints {
- sb.WriteString("\n")
-
- // Checkpoint header
- taskMarker := ""
- if cp.IsTaskCheckpoint {
- taskMarker = " [Task]"
- }
- fmt.Fprintf(&sb, "─── Checkpoint %d [%s] %s%s ───\n",
- cp.Index, cp.ShortID, cp.Timestamp.Format("2006-01-02 15:04"), taskMarker)
- sb.WriteString("\n")
-
- // Display all interactions in this checkpoint
- for i, inter := range cp.Interactions {
- // For multiple interactions, add a sub-header
- if len(cp.Interactions) > 1 {
- fmt.Fprintf(&sb, "### Interaction %d\n\n", i+1)
- }
-
- // Prompt section
- if inter.Prompt != "" {
- sb.WriteString("## Prompt\n\n")
- sb.WriteString(inter.Prompt)
- sb.WriteString("\n\n")
- }
-
- // Response section
- if len(inter.Responses) > 0 {
- sb.WriteString("## Responses\n\n")
- sb.WriteString(strings.Join(inter.Responses, "\n\n"))
- sb.WriteString("\n\n")
- }
-
- // Files modified for this interaction
- if len(inter.Files) > 0 {
- fmt.Fprintf(&sb, "Files Modified (%d):\n", len(inter.Files))
- for _, file := range inter.Files {
- fmt.Fprintf(&sb, " - %s\n", file)
- }
- sb.WriteString("\n")
- }
- }
-
- // If no interactions, show message and/or files
- if len(cp.Interactions) == 0 {
- // Show commit message as summary when no transcript available
- if cp.Message != "" {
- sb.WriteString(cp.Message)
- sb.WriteString("\n\n")
- }
- // Show aggregate files if available
- if len(cp.Files) > 0 {
- fmt.Fprintf(&sb, "Files Modified (%d):\n", len(cp.Files))
- for _, file := range cp.Files {
- fmt.Fprintf(&sb, " - %s\n", file)
- }
- }
- }
- }
-
- return sb.String()
-}
-
-// pagerLookupEnv is overridable for tests so pager env-gate behavior can be
-// asserted without depending on the host's PAGER / LESS settings.
-var pagerLookupEnv = os.Getenv
-
-// buildPagerCmd constructs the pager subprocess and injects LESS=-R when the
-// default Unix pager is less and the user has not customized PAGER or LESS.
-func buildPagerCmd(ctx context.Context) (*exec.Cmd, string) {
- pager := pagerLookupEnv(pagerEnvVar)
- if pager == "" {
- if runtime.GOOS == windowsGOOS {
- pager = "more"
- } else {
- pager = lessPagerName
- }
- }
-
- cmd := exec.CommandContext(ctx, pager)
- if pager == lessPagerName && pagerLookupEnv(pagerEnvVar) == "" && pagerLookupEnv(lessEnvVar) == "" {
- cmd.Env = upsertEnv(os.Environ(), lessEnvVar, "-R")
- }
- return cmd, pager
-}
-
-func upsertEnv(env []string, key, value string) []string {
- prefix := key + "="
- entry := prefix + value
- result := make([]string, 0, len(env)+1)
- replaced := false
- for _, e := range env {
- if strings.HasPrefix(e, prefix) {
- if !replaced {
- result = append(result, entry)
- replaced = true
- }
- continue
- }
- result = append(result, e)
- }
- if !replaced {
- result = append(result, entry)
- }
- return result
-}
-
-// removeEnvKey returns env with every entry for key dropped. Useful when a
-// outputWithPager outputs content through a pager if stdout is a terminal and content is long.
-func outputWithPager(w io.Writer, content string) {
- // Check if we're writing to stdout and it's a terminal
- if f, ok := w.(*os.File); ok && f == os.Stdout && interactive.IsTerminalWriter(w) {
- // Get terminal height
- _, height, err := term.GetSize(int(f.Fd())) //nolint:gosec // G115: same as above
- if err != nil {
- height = 24 // Default fallback
- }
-
- // Count lines in content
- lineCount := strings.Count(content, "\n")
-
- // Use pager if content exceeds terminal height
- if lineCount > height-2 {
- // Use context.Background() intentionally — pagers are interactive
- // processes that handle signals (including SIGINT) themselves.
- // Using the cancellable ctx would cause exec.CommandContext to
- // SIGKILL the pager on Ctrl+C, preventing it from restoring
- // terminal state (raw mode, echo, etc.).
- cmd, _ := buildPagerCmd(context.Background())
- cmd.Stdin = strings.NewReader(content)
- cmd.Stdout = f
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- // Fallback to direct output if pager fails
- fmt.Fprint(w, content)
- }
- return
- }
- }
-
- // Direct output for non-terminal or short content
- fmt.Fprint(w, content)
-}
-
-// Constants for formatting output
-const (
- // maxIntentDisplayLength is the maximum length for intent text before truncation
- maxIntentDisplayLength = 80
- // maxMessageDisplayLength is the maximum length for checkpoint messages before truncation
- maxMessageDisplayLength = 80
- // maxPromptDisplayLength is the maximum length for session prompts before truncation
- maxPromptDisplayLength = 60
- // checkpointIDDisplayLength is the number of characters to show from checkpoint IDs
- checkpointIDDisplayLength = 12
-)
-
-// formatBranchCheckpoints formats checkpoint information for a branch.
-// Groups commits by checkpoint ID and shows the prompt for each checkpoint.
-// If sessionFilter is non-empty, only shows checkpoints matching that session ID (or prefix).
-func formatBranchCheckpoints(w io.Writer, branchName string, points []strategy.RewindPoint, sessionFilter string) string {
- var sb strings.Builder
- styles := newStatusStyles(w)
-
- // Filter by session if specified (must happen before counting)
- if sessionFilter != "" {
- var filtered []strategy.RewindPoint
- for _, p := range points {
- if p.SessionID == sessionFilter || strings.HasPrefix(p.SessionID, sessionFilter) {
- filtered = append(filtered, p)
- }
- }
- points = filtered
- }
-
- // Group by checkpoint ID so the count matches the rendered group count
- groups := groupByCheckpointID(points)
-
- branchRows := []explainRow{
- {Label: "branch", Value: branchName},
- }
- if sessionFilter != "" {
- branchRows = append(branchRows, explainRow{Label: "session", Value: sessionFilter})
- }
- branchRows = append(branchRows, explainRow{Label: "checkpoints", Value: strconv.Itoa(len(groups))})
-
- sb.WriteString(styles.metadataRows(branchRows))
- sb.WriteString("\n")
-
- if len(groups) == 0 {
- sb.WriteString("No checkpoints found on this branch.\n")
- sb.WriteString("Checkpoints will appear here after you save changes during an agent session.\n")
- return sb.String()
- }
-
- // Output each checkpoint group
- for _, group := range groups {
- formatCheckpointGroup(&sb, group, styles)
- sb.WriteString("\n")
- }
-
- return sb.String()
-}
-
-// checkpointGroup represents a group of commits sharing the same checkpoint ID.
-type checkpointGroup struct {
- checkpointID string
- prompt string
- isTemporary bool // true if any commit is not logs-only (can be rewound)
- isTask bool // true if this is a task checkpoint
- commits []commitEntry
-}
-
-// commitEntry represents a single git commit within a checkpoint.
-type commitEntry struct {
- date time.Time
- gitSHA string // short git SHA
- message string
-}
-
-// groupByCheckpointID groups rewind points by their checkpoint ID.
-// Returns groups sorted by latest commit timestamp (most recent first).
-func groupByCheckpointID(points []strategy.RewindPoint) []checkpointGroup {
- if len(points) == 0 {
- return nil
- }
-
- // Build map of checkpoint ID -> group
- groupMap := make(map[string]*checkpointGroup)
- var order []string // Track insertion order for stable iteration
-
- for _, point := range points {
- // Determine the checkpoint ID to use for grouping
- cpID := point.CheckpointID.String()
- if cpID == "" {
- // Temporary checkpoints: group by session ID to preserve per-session prompts
- // Use session ID prefix for readability (format: YYYY-MM-DD-uuid)
- cpID = point.SessionID
- if cpID == "" {
- cpID = "temporary" // Fallback if no session ID
- }
- }
-
- group, exists := groupMap[cpID]
- if !exists {
- group = &checkpointGroup{
- checkpointID: cpID,
- prompt: point.SessionPrompt,
- isTemporary: !point.IsLogsOnly,
- isTask: point.IsTaskCheckpoint,
- }
- groupMap[cpID] = group
- order = append(order, cpID)
- }
-
- // Short git SHA (7 chars)
- gitSHA := point.ID
- if len(gitSHA) > 7 {
- gitSHA = gitSHA[:7]
- }
-
- group.commits = append(group.commits, commitEntry{
- date: point.Date,
- gitSHA: gitSHA,
- message: point.Message,
- })
-
- // Update flags - if any commit is temporary/task, the group is too
- if !point.IsLogsOnly {
- group.isTemporary = true
- }
- if point.IsTaskCheckpoint {
- group.isTask = true
- }
- // Update prompt if the group's prompt is empty but this point has one
- if group.prompt == "" && point.SessionPrompt != "" {
- group.prompt = point.SessionPrompt
- }
- }
-
- // Sort commits within each group by date (most recent first)
- for _, group := range groupMap {
- sort.Slice(group.commits, func(i, j int) bool {
- return group.commits[i].date.After(group.commits[j].date)
- })
- }
-
- // Build result slice in order, then sort by latest commit
- result := make([]checkpointGroup, 0, len(order))
- for _, cpID := range order {
- result = append(result, *groupMap[cpID])
- }
-
- // Sort groups by latest commit timestamp (most recent first)
- sort.Slice(result, func(i, j int) bool {
- // Each group's commits are already sorted, so first commit is latest
- if len(result[i].commits) == 0 {
- return false
- }
- if len(result[j].commits) == 0 {
- return true
- }
- return result[i].commits[0].date.After(result[j].commits[0].date)
- })
-
- return result
-}
-
-// formatCheckpointGroup formats a single checkpoint group for display.
-// The list view headline puts the checkpoint ID first (in bold orange),
-// followed by indicators and the prompt — which cascades from
-// SessionPrompt → latest commit message → dimmed `(no prompt recorded)`.
-func formatCheckpointGroup(sb *strings.Builder, group checkpointGroup, styles statusStyles) {
- cpID := group.checkpointID
- if len(cpID) > checkpointIDDisplayLength {
- cpID = cpID[:checkpointIDDisplayLength]
- }
-
- // Indicators (Task / temporary). Skip [temporary] when cpID already says so.
- var indicators []string
- if group.isTask {
- indicators = append(indicators, "[Task]")
- }
- if group.isTemporary && cpID != "temporary" {
- indicators = append(indicators, "[temporary]")
- }
-
- // Prompt cascade: SessionPrompt → latest commit message → dimmed placeholder.
- // Quote user prompts; commit subjects render bare.
- var promptText string
- var promptIsPlaceholder bool
- switch {
- case group.prompt != "":
- promptText = fmt.Sprintf("%q", strategy.TruncateDescription(group.prompt, maxPromptDisplayLength))
- case len(group.commits) > 0 && group.commits[0].message != "":
- promptText = strategy.TruncateDescription(group.commits[0].message, maxPromptDisplayLength)
- default:
- promptText = "(no prompt recorded)"
- promptIsPlaceholder = true
- }
- if promptIsPlaceholder {
- promptText = styles.render(styles.dim, promptText)
- }
-
- // Build suffix: "[Task] [temporary] " with two-space separators.
- parts := append([]string{}, indicators...)
- parts = append(parts, promptText)
- suffix := strings.Join(parts, " ")
-
- sb.WriteString(styles.listIdentityBullet(cpID, suffix))
-
- // List commits under this checkpoint.
- for _, commit := range group.commits {
- dateTimeStr := commit.date.Format("01-02 15:04")
- message := strategy.TruncateDescription(commit.message, maxMessageDisplayLength)
- fmt.Fprintf(sb, " %s (%s) %s\n", dateTimeStr, commit.gitSHA, message)
- }
-}
-
-// countLines counts the number of lines in a byte slice.
-// For JSONL content (where each line ends with \n), this returns the line count.
-// Empty content returns 0.
-func countLines(content []byte) int {
- if len(content) == 0 {
- return 0
- }
- count := 0
- for _, b := range content {
- if b == '\n' {
- count++
- }
- }
- return count
-}
-
-// transcriptOffset returns the appropriate offset for scoping a transcript.
-// For Claude Code (JSONL), this is the line count. For Gemini (JSON), this is the message count.
-func transcriptOffset(transcriptBytes []byte, agentType types.AgentType) int {
- switch agentType {
- case agent.AgentTypeGemini:
- t, err := geminicli.ParseTranscript(transcriptBytes)
- if err != nil {
- return 0
- }
- return len(t.Messages)
- case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
- return countLines(transcriptBytes)
- }
- return countLines(transcriptBytes)
-}
-
-// hasCodeChanges returns true if the commit has changes to non-metadata files.
-// Uses a full tree diff to distinguish code changes from .trace/ metadata-only changes.
-// Returns false only if the commit has a parent AND only modified .trace/ metadata files.
-//
-// WARNING: This is expensive via go-git (resolves many tree/blob objects from packfiles).
-// For list views with many checkpoints, use hasAnyChanges instead.
-func hasCodeChanges(commit *object.Commit) bool {
- // First commit on shadow branch captures working copy state - always meaningful
- if commit.NumParents() == 0 {
- return true
- }
-
- parent, err := commit.Parent(0)
- if err != nil {
- return true // Can't check, assume meaningful
- }
-
- commitTree, err := commit.Tree()
- if err != nil {
- return true
- }
-
- parentTree, err := parent.Tree()
- if err != nil {
- return true
- }
-
- changes, err := parentTree.Diff(commitTree)
- if err != nil {
- return true
- }
-
- // Check if any non-metadata file was changed
- for _, change := range changes {
- name := change.To.Name
- if name == "" {
- name = change.From.Name
- }
- // Skip .trace/ metadata files
- if !strings.HasPrefix(name, ".trace/") {
- return true
- }
- }
-
- return false
-}
-
-// hasAnyChanges is a lightweight alternative to hasCodeChanges that compares
-// tree hashes without doing a full diff. Returns true if the commit's tree
-// differs from its parent's tree. This may include metadata-only changes,
-// but is O(1) instead of O(files) — suitable for list views.
-func hasAnyChanges(commit *object.Commit) bool {
- if commit.NumParents() == 0 {
- return true
- }
- parent, err := commit.Parent(0)
- if err != nil {
- return true
- }
- return commit.TreeHash != parent.TreeHash
-}
diff --git a/cli/explain_2.go b/cli/explain_2.go
new file mode 100644
index 0000000..b5c54b9
--- /dev/null
+++ b/cli/explain_2.go
@@ -0,0 +1,826 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ "github.com/GrayCodeAI/trace/cli/agent/external"
+ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/opencode"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/summarize"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/GrayCodeAI/trace/cli/transcript"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, error) {
+ repo, err := openRepository(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("not a git repository: %w", err)
+ }
+
+ v2URL, err := remote.FetchURL(ctx)
+ if err != nil {
+ logging.Debug(
+ ctx, "explain: using origin for v2 store fetch remote",
+ slog.String("error", err.Error()),
+ )
+ v2URL = ""
+ }
+
+ // FetchBlobsByHash uses `git fetch-pack` for blob SHAs (porcelain
+ // `git fetch` fails against partial-clone repos with "did not send all
+ // necessary objects"). Falls back to a full metadata-branch fetch if
+ // fetch-pack also can't reach the blobs.
+ v1Store := checkpoint.NewGitStore(repo)
+ v1Store.SetBlobFetcher(FetchBlobsByHash)
+
+ v2Store := checkpoint.NewV2GitStore(repo, v2URL)
+ v2Store.SetBlobFetcher(FetchBlobsByHash)
+
+ lookup := &explainCheckpointLookup{
+ repo: repo,
+ v1Store: v1Store,
+ v2Store: v2Store,
+ preferCheckpointsV2: settings.IsCheckpointsV2Enabled(ctx),
+ }
+
+ committed, err := listCommittedForExplain(ctx, lookup.v1Store, lookup.v2Store, lookup.preferCheckpointsV2)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list checkpoints: %w", err)
+ }
+ lookup.committed = committed
+ return lookup, nil
+}
+
+func listCommittedForExplain(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, preferCheckpointsV2 bool) ([]checkpoint.CommittedInfo, error) {
+ v1Committed, v1Err := v1Store.ListCommitted(ctx)
+
+ if !preferCheckpointsV2 {
+ if v1Err != nil {
+ return nil, fmt.Errorf("listing v1 checkpoints: %w", v1Err)
+ }
+ return v1Committed, nil
+ }
+
+ v2Committed, v2Err := v2Store.ListCommitted(ctx)
+ if v2Err != nil {
+ logging.Debug(
+ ctx, "v2 ListCommitted failed, using v1 only",
+ slog.String("error", v2Err.Error()),
+ )
+ if v1Err != nil {
+ return nil, fmt.Errorf("listing checkpoints: %w", v1Err)
+ }
+ return v1Committed, nil
+ }
+
+ if v1Err != nil {
+ logging.Debug(
+ ctx, "v1 ListCommitted failed, returning v2 only",
+ slog.String("error", v1Err.Error()),
+ )
+ return v2Committed, nil
+ }
+
+ // Merge v2 and v1 results so pre-v2 checkpoints remain visible during transition.
+ seen := make(map[id.CheckpointID]struct{}, len(v2Committed))
+ for _, c := range v2Committed {
+ seen[c.CheckpointID] = struct{}{}
+ }
+ committedCheckpoints := make([]checkpoint.CommittedInfo, 0, len(v2Committed)+len(v1Committed))
+ committedCheckpoints = append(committedCheckpoints, v2Committed...)
+ for _, c := range v1Committed {
+ if _, ok := seen[c.CheckpointID]; !ok {
+ committedCheckpoints = append(committedCheckpoints, c)
+ }
+ }
+ return committedCheckpoints, nil
+}
+
+func readLatestSessionContentForExplain(ctx context.Context, reader checkpoint.CommittedReader, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) {
+ if summary == nil || len(summary.Sessions) == 0 {
+ return nil, checkpoint.ErrCheckpointNotFound
+ }
+
+ latestIndex := len(summary.Sessions) - 1
+ content, err := reader.ReadSessionContent(ctx, checkpointID, latestIndex)
+ if err != nil {
+ return nil, fmt.Errorf("reading session %d content: %w", latestIndex, err)
+ }
+ return content, nil
+}
+
+// resolvePromptTree picks the best metadata tree for reading session prompts.
+// Prefers v2 when enabled (same sharded layout as v1), falls back to v1.
+func resolvePromptTree(v1Tree, v2Tree *object.Tree, preferV2 bool) *object.Tree {
+ if preferV2 && v2Tree != nil {
+ return v2Tree
+ }
+ if v1Tree != nil {
+ return v1Tree
+ }
+ return v2Tree // Last resort: use v2 even if not preferred
+}
+
+// readV2ContentFromMain reads session content from the v2 /main ref only —
+// metadata, prompts, and the compact transcript (transcript.jsonl). This is the
+// primary read path for default display modes that don't need the raw transcript
+// stored on /full/* refs.
+func readV2ContentFromMain(ctx context.Context, v2Reader *checkpoint.V2GitStore, checkpointID id.CheckpointID, summary *checkpoint.CheckpointSummary) (*checkpoint.SessionContent, error) {
+ if summary == nil || len(summary.Sessions) == 0 {
+ return nil, checkpoint.ErrCheckpointNotFound
+ }
+
+ latestIndex := len(summary.Sessions) - 1
+
+ content, err := v2Reader.ReadSessionMetadataAndPrompts(ctx, checkpointID, latestIndex)
+ if err != nil {
+ return nil, fmt.Errorf("reading session %d metadata: %w", latestIndex, err)
+ }
+
+ // ReadSessionMetadataAndPrompts reads the compact transcript from the same
+ // session tree. Reset transcript offsets when compact data is present.
+ if len(content.Transcript) > 0 {
+ content.Metadata.CheckpointTranscriptStart = 0
+ //lint:ignore SA1019 // Set for backward compat with older CLI readers
+ content.Metadata.TranscriptLinesAtStart = 0
+ return content, nil
+ }
+
+ // No compact transcript on /main — fall back to the raw transcript on
+ // /full/current for the most accurate display before resorting to prompt.txt.
+ fullContent, fullErr := v2Reader.ReadSessionContent(ctx, checkpointID, latestIndex)
+ if fullErr == nil && len(fullContent.Transcript) > 0 {
+ content.Transcript = fullContent.Transcript
+ return content, nil
+ }
+
+ // Last resort: return metadata + prompts without transcript.
+ return content, nil
+}
+
+// generateCheckpointSummary generates an AI summary for a checkpoint and persists it.
+// The summary is generated from the scoped transcript (only this checkpoint's portion),
+// not the trace session transcript.
+func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error {
+ // Check if summary already exists
+ if content.Metadata.Summary != nil && !force {
+ return renderExplainFailure(errW, "Summary already exists", []explainRow{
+ {Label: "id", Value: checkpointID.String()},
+ {Label: "try", Value: fmt.Sprintf("trace explain --generate --force %s", checkpointID)},
+ }, fmt.Errorf("checkpoint %s already has a summary", checkpointID))
+ }
+
+ // Check if transcript exists
+ if len(content.Transcript) == 0 {
+ return renderExplainFailure(errW, "Checkpoint has no transcript", []explainRow{
+ {Label: "id", Value: checkpointID.String()},
+ }, fmt.Errorf("checkpoint %s has no transcript to summarize", checkpointID))
+ }
+
+ // Scope the transcript to only this checkpoint's portion
+ scopedTranscript := scopeTranscriptForCheckpoint(content.Transcript, content.Metadata.GetTranscriptStart(), content.Metadata.Agent)
+ if len(scopedTranscript) == 0 {
+ return renderExplainFailure(errW, "Checkpoint has no transcript content (scoped)", []explainRow{
+ {Label: "id", Value: checkpointID.String()},
+ }, fmt.Errorf("checkpoint %s has no transcript content for this checkpoint (scoped)", checkpointID))
+ }
+ provider, err := resolveCheckpointSummaryProvider(ctx, w)
+ if err != nil {
+ return fmt.Errorf("failed to resolve summary provider: %w", err)
+ }
+ scopedTranscript = maybeCompactExternalTranscriptForSummary(ctx, scopedTranscript, content.Metadata.Agent)
+
+ // Generate summary using shared helper
+ logging.Info(ctx, "generating checkpoint summary")
+ if errW != nil {
+ fmt.Fprintln(errW, "Generating checkpoint summary...")
+ }
+
+ start := time.Now()
+ summary, appliedDeadline, err := generateCheckpointAISummary(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, provider.Generator)
+ if err != nil {
+ label, rows, structured := formatCheckpointSummaryError(err, appliedDeadline)
+ styles := newStatusStyles(errW)
+ fmt.Fprint(errW, styles.renderFailure(label, rows))
+ return NewSilentError(structured)
+ }
+ elapsed := time.Since(start)
+
+ // Persist to both stores; at least one must succeed.
+ v1Err := v1Store.UpdateSummary(ctx, checkpointID, summary)
+ var v2Err error
+ if v2Store != nil {
+ v2Err = v2Store.UpdateSummary(ctx, checkpointID, summary)
+ }
+
+ switch {
+ case v1Err != nil && (v2Store == nil || v2Err != nil):
+ // No store succeeded — hard error.
+ if v2Err != nil {
+ return fmt.Errorf("failed to save summary: v1: %w, v2: %w", v1Err, v2Err)
+ }
+ return fmt.Errorf("failed to save summary: %w", v1Err)
+ case v1Err != nil:
+ logging.Debug(
+ ctx, "v1 UpdateSummary failed (v2 succeeded)",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("error", v1Err.Error()),
+ )
+ case v2Err != nil:
+ logging.Debug(
+ ctx, "v2 UpdateSummary failed (v1 succeeded)",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("error", v2Err.Error()),
+ )
+ }
+
+ styles := newStatusStyles(w)
+ rows := summaryProviderRows(provider)
+ rows = append(rows, explainRow{Label: "duration", Value: formatSummaryDuration(elapsed)})
+ fmt.Fprint(w, styles.renderSuccess(fmt.Sprintf("Summary generated for %s", checkpointID), rows))
+ return nil
+}
+
+// formatSummaryDuration rounds wall-clock generation time to a human-friendly value.
+func formatSummaryDuration(d time.Duration) string {
+ return d.Round(100 * time.Millisecond).String()
+}
+
+func maybeCompactExternalTranscriptForSummary(ctx context.Context, scopedTranscript []byte, agentType types.AgentType) []byte {
+ if transcriptHasSummaryContent(scopedTranscript, agentType) {
+ return scopedTranscript
+ }
+
+ ag, err := agent.GetByAgentType(agentType)
+ if err != nil {
+ external.DiscoverAndRegister(ctx)
+ ag, err = agent.GetByAgentType(agentType)
+ }
+ if err != nil || !external.IsExternal(ag) {
+ return scopedTranscript
+ }
+
+ compactor, ok := agent.AsTranscriptCompactor(ag)
+ if !ok {
+ return scopedTranscript
+ }
+
+ tmpFile, err := os.CreateTemp("", "trace-summary-transcript-*.jsonl")
+ if err != nil {
+ logging.Debug(ctx, "external summary compaction unavailable",
+ slog.String("agent", string(agentType)),
+ slog.String("error", err.Error()))
+ return scopedTranscript
+ }
+ tmpPath := tmpFile.Name()
+ defer func() {
+ if removeErr := os.Remove(tmpPath); removeErr != nil {
+ logging.Debug(ctx, "failed to remove temporary summary transcript",
+ slog.String("path", tmpPath),
+ slog.String("error", removeErr.Error()))
+ }
+ }()
+
+ if _, err := tmpFile.Write(scopedTranscript); err != nil {
+ _ = tmpFile.Close()
+ logging.Debug(ctx, "external summary compaction transcript write failed",
+ slog.String("agent", string(agentType)),
+ slog.String("error", err.Error()))
+ return scopedTranscript
+ }
+ if err := tmpFile.Close(); err != nil {
+ logging.Debug(ctx, "external summary compaction transcript close failed",
+ slog.String("agent", string(agentType)),
+ slog.String("error", err.Error()))
+ return scopedTranscript
+ }
+
+ compacted, err := compactor.CompactTranscript(ctx, tmpPath)
+ if err != nil || compacted == nil || len(compacted.Transcript) == 0 {
+ if err != nil {
+ logging.Debug(ctx, "external summary compaction failed",
+ slog.String("agent", string(agentType)),
+ slog.String("error", err.Error()))
+ }
+ return scopedTranscript
+ }
+
+ redacted, err := redact.JSONLBytes(compacted.Transcript)
+ if err != nil {
+ logging.Debug(ctx, "external summary compaction redaction failed",
+ slog.String("agent", string(agentType)),
+ slog.String("error", err.Error()))
+ return scopedTranscript
+ }
+ redactedTranscript := redacted.Bytes()
+ if !transcriptHasSummaryContent(redactedTranscript, agentType) {
+ return scopedTranscript
+ }
+
+ logging.Debug(ctx, "using external compact transcript for summary generation",
+ slog.String("agent", string(agentType)))
+ return redactedTranscript
+}
+
+func transcriptHasSummaryContent(transcriptBytes []byte, agentType types.AgentType) bool {
+ entries, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
+ return err == nil && len(entries) > 0
+}
+
+// generateCheckpointAISummary returns the generated summary, the effective
+// deadline applied to the underlying call (which may be shorter than
+// checkpointSummaryTimeout if the parent context had an earlier deadline),
+// and any error. The effective deadline is returned so the caller can render
+// the true timeout value in user-facing error messages instead of always
+// showing the package default.
+func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, filesTouched []string, agentType types.AgentType, generator summarize.Generator) (*checkpoint.Summary, time.Duration, error) {
+ timeoutCtx, cancel := context.WithTimeout(ctx, checkpointSummaryTimeout)
+ timeoutDuration := checkpointSummaryTimeout
+ if deadline, ok := timeoutCtx.Deadline(); ok {
+ timeoutDuration = time.Until(deadline)
+ }
+ defer cancel()
+
+ // scopedTranscript is either read from checkpoint storage (redacted on
+ // write) or replaced by external compact output redacted before use.
+ summary, err := generateTranscriptSummary(timeoutCtx, redact.AlreadyRedacted(scopedTranscript), filesTouched, agentType, generator)
+ if err != nil {
+ // Only classify as ctx cancel/deadline when the error chain actually
+ // contains the sentinel. Relying on timeoutCtx.Err() here loses typed
+ // errors (e.g. *ClaudeError) when the subprocess returned a real
+ // structured failure while timeoutCtx.Err() is non-nil for any reason
+ // (parent cancelled, deadline already elapsed, etc.).
+ if errors.Is(err, context.Canceled) {
+ return nil, timeoutDuration, fmt.Errorf("summary generation canceled: %w", err)
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ return nil, timeoutDuration, fmt.Errorf("summary generation timed out after %s: %w", formatSummaryTimeout(timeoutDuration), err)
+ }
+ return nil, timeoutDuration, err
+ }
+
+ return summary, timeoutDuration, nil
+}
+
+// formatCheckpointSummaryError maps typed Claude CLI errors and context
+// sentinels to a structured failure block: a user-visible label, supporting
+// rows, and a structured error suitable for wrapping in NewSilentError.
+//
+// The styled rendering happens in the caller (generateCheckpointSummary), which
+// renders to errW via newStatusStyles(...).renderFailure(label, rows). This
+// split keeps the formatting policy in one place (the failure block) while
+// letting the caller still return a *SilentError for main.go's exit handling.
+func formatCheckpointSummaryError(err error, deadline time.Duration) (string, []explainRow, error) {
+ var claudeErr *claudecode.ClaudeError
+ switch {
+ case errors.As(err, &claudeErr):
+ switch claudeErr.Kind { //nolint:exhaustive // ClaudeErrorUnknown handled by default
+ case claudecode.ClaudeErrorAuth:
+ label := "Claude authentication failed"
+ rows := []explainRow{
+ {Label: "try", Value: "run `claude login` and retry"},
+ }
+ if claudeErr.Message != "" {
+ rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
+ }
+ //nolint:staticcheck // ST1005: Claude is a proper noun
+ //lint:ignore ST1005 // Claude is a proper noun
+ return label, rows, fmt.Errorf("Claude authentication failed%s", formatMessageSuffix(claudeErr.Message))
+ case claudecode.ClaudeErrorRateLimit:
+ label := "Claude rejected the summary request due to rate limits or quota"
+ rows := []explainRow{
+ {Label: "try", Value: "wait and retry"},
+ }
+ if claudeErr.Message != "" {
+ rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
+ }
+ //nolint:staticcheck // ST1005: Claude is a proper noun
+ //lint:ignore ST1005 // Claude is a proper noun
+ return label, rows, fmt.Errorf("Claude rejected the summary request due to rate limits or quota%s", formatMessageSuffix(claudeErr.Message))
+ case claudecode.ClaudeErrorConfig:
+ label := "Claude rejected the summary request"
+ rows := []explainRow{
+ {Label: "try", Value: "check your Claude CLI config and selected model"},
+ }
+ if claudeErr.Message != "" {
+ rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...)
+ }
+ //nolint:staticcheck // ST1005: Claude is a proper noun
+ //lint:ignore ST1005 // Claude is a proper noun
+ return label, rows, fmt.Errorf("Claude rejected the summary request%s", formatMessageSuffix(claudeErr.Message))
+ case claudecode.ClaudeErrorCLIMissing:
+ label := "Claude CLI is not installed or not on PATH"
+ //nolint:staticcheck // ST1005: Claude is a proper noun
+ //lint:ignore ST1005 // Claude is a proper noun
+ return label, nil, errors.New("Claude CLI is not installed or not on PATH")
+ default:
+ label := "Claude failed to generate the summary"
+ suffix := formatClaudeErrorSuffix(claudeErr)
+ rows := []explainRow{
+ {Label: "detail", Value: strings.TrimPrefix(strings.TrimPrefix(suffix, ": "), " ")},
+ }
+ //nolint:staticcheck // ST1005: Claude is a proper noun
+ //lint:ignore ST1005 // Claude is a proper noun
+ return label, rows, fmt.Errorf("Claude failed to generate the summary%s", suffix)
+ }
+ case errors.Is(err, context.DeadlineExceeded):
+ // Deliberately provider-neutral: explain --generate supports multiple
+ // summary providers (claude-code, codex, gemini, ...), so hardcoding
+ // "Claude" / "sonnet" / "Anthropic" here would misdirect users who
+ // selected a different provider in .trace/settings.json.
+ label := "Summary generation timed out after " + formatSummaryTimeout(deadline)
+ rows := []explainRow{
+ {Label: "causes", Value: ""},
+ {Label: "", Value: "• the selected model is taking longer than expected on a large transcript"},
+ {Label: "", Value: "• the summary provider's CLI cannot reach its API (network, VPN, firewall)"},
+ {Label: "", Value: "• the provider's API is degraded"},
+ {Label: "try", Value: "run the provider CLI directly to confirm it works"},
+ }
+ return label, rows, fmt.Errorf("summary generation did not return within the %s safety deadline", formatSummaryTimeout(deadline))
+ case errors.Is(err, context.Canceled):
+ return "Summary generation canceled", nil, errors.New("summary generation canceled")
+ default:
+ return "Failed to generate summary", []explainRow{{Label: "detail", Value: err.Error()}}, fmt.Errorf("failed to generate summary: %w", err)
+ }
+}
+
+// formatMessageSuffix formats ": " when msg is non-empty and "" otherwise.
+// Used by the Auth / RateLimit / Config branches of formatCheckpointSummaryError
+// to avoid rendering a bare colon when ClaudeError.Message is empty (reachable
+// when the CLI envelope is is_error:true with result:null but a real status).
+func formatMessageSuffix(msg string) string {
+ if msg == "" {
+ return ""
+ }
+ return ": " + msg
+}
+
+// formatClaudeErrorSuffix builds a diagnostic suffix for user-facing output
+// when we fall through to the default "failed to generate the summary" path.
+// Prefers the envelope Message, falls back to HTTP status, then exit code,
+// so the user never sees a bare "Claude failed to generate the summary:"
+// with nothing after the colon (which happens when Claude returns
+// is_error:true with result:null, or when the subprocess crashes with no
+// stderr output). ExitCode < 0 means the subprocess did not produce a real
+// exit code (e.g. launch failure) — render that as "abnormal termination"
+// rather than the misleading "exited with code -1".
+func formatClaudeErrorSuffix(e *claudecode.ClaudeError) string {
+ if e.Message != "" {
+ return ": " + e.Message
+ }
+ switch {
+ case e.APIStatus != 0:
+ return fmt.Sprintf(" (Anthropic API returned HTTP %d)", e.APIStatus)
+ case e.ExitCode > 0:
+ return fmt.Sprintf(" (claude CLI exited with code %d)", e.ExitCode)
+ case e.ExitCode < 0:
+ return " (claude CLI terminated abnormally — no exit code captured)"
+ default:
+ return " (no diagnostic detail available from Claude CLI)"
+ }
+}
+
+func formatSummaryTimeout(d time.Duration) string {
+ if d < 0 {
+ d = 0
+ }
+ if d < time.Second {
+ return d.Round(10 * time.Millisecond).String()
+ }
+ return d.Round(time.Second).String()
+}
+
+// explainTemporaryCheckpoint finds and formats a temporary checkpoint by shadow commit hash prefix.
+// Returns the formatted output, whether the checkpoint was found, and an
+// optional error. When err is non-nil, the function has already rendered a
+// styled failure block to errW; the caller should wrap and return as
+// SilentError without printing again.
+// Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints
+// created from different base commits (e.g., if HEAD advanced since session start).
+// The writer w is used for raw transcript output to bypass the pager.
+func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store *checkpoint.GitStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) {
+ // List temporary checkpoints from ALL shadow branches
+ // This ensures we find checkpoints even if HEAD has advanced since the session started
+ tempCheckpoints, err := store.ListAllTemporaryCheckpoints(ctx, "", branchCheckpointsLimit)
+ if err != nil {
+ return "", false, nil //nolint:nilerr // best-effort: shadow-branch listing failure is reported as found=false; caller then falls back to ErrCheckpointNotFound with a user-facing hint instead of a raw git error
+ }
+
+ // Find checkpoints matching the SHA prefix - check for ambiguity
+ var matches []checkpoint.TemporaryCheckpointInfo
+ for _, tc := range tempCheckpoints {
+ if strings.HasPrefix(tc.CommitHash.String(), shaPrefix) {
+ matches = append(matches, tc)
+ }
+ }
+
+ if len(matches) == 0 {
+ return "", false, nil
+ }
+
+ if len(matches) > 1 {
+ // Multiple matches: render styled failure block, return SilentError.
+ ambiguous := make([]ambiguousMatch, 0, len(matches))
+ for _, m := range matches {
+ shortID := m.CommitHash.String()
+ if len(shortID) > 7 {
+ shortID = shortID[:7]
+ }
+ ambiguous = append(ambiguous, ambiguousMatch{
+ ShortID: shortID,
+ Timestamp: m.Timestamp,
+ SessionID: m.SessionID,
+ })
+ }
+ renderAmbiguousPrefixFailure(errW, shaPrefix, "temporary checkpoints", ambiguous)
+ return "", false, NewSilentError(fmt.Errorf("%w: %s matches %d temporary checkpoints", errAmbiguousCommitPrefix, shaPrefix, len(matches)))
+ }
+
+ tc := matches[0]
+
+ // Get shadow commit and tree to read metadata
+ shadowCommit, commitErr := repo.CommitObject(tc.CommitHash)
+ if commitErr != nil {
+ return "", false, nil //nolint:nilerr // best-effort: shadow commit may have been GC'd or pruned; treat as not-found so the caller reports ErrCheckpointNotFound rather than an internal git error
+ }
+
+ shadowTree, treeErr := shadowCommit.Tree()
+ if treeErr != nil {
+ return "", false, nil //nolint:nilerr // best-effort: a shadow commit without a readable tree is corrupt/partial; treat as not-found so the caller reports ErrCheckpointNotFound rather than an internal git error
+ }
+
+ // Read agent type from shadow branch metadata (stored during checkpoint creation)
+ agentType := strategy.ReadAgentTypeFromTree(shadowTree, tc.MetadataDir)
+
+ // Handle raw transcript output
+ if rawTranscript {
+ transcriptBytes, transcriptErr := store.GetTranscriptFromCommit(ctx, tc.CommitHash, tc.MetadataDir, agentType)
+ if transcriptErr != nil || len(transcriptBytes) == 0 {
+ shortID := tc.CommitHash.String()[:7]
+ return "", false, renderExplainFailure(errW, "Checkpoint has no transcript", []explainRow{
+ {Label: "id", Value: shortID},
+ }, fmt.Errorf("checkpoint %s has no transcript", shortID))
+ }
+ // Write directly to writer (no pager, no formatting) - matches committed checkpoint behavior
+ if _, writeErr := fmt.Fprint(w, string(transcriptBytes)); writeErr != nil {
+ return "", false, fmt.Errorf("failed to write transcript: %w", writeErr)
+ }
+ return "", true, nil
+ }
+
+ // Read prompts from shadow branch
+ sessionPrompt := strategy.ReadSessionPromptFromTree(shadowTree, tc.MetadataDir)
+
+ // Build output similar to formatCheckpointOutput but for temporary
+ var sb strings.Builder
+ shortID := tc.CommitHash.String()[:7]
+ styles := newStatusStyles(w)
+
+ label := fmt.Sprintf("Checkpoint %s [temporary]", shortID)
+ rows := []explainRow{
+ {Label: "session", Value: tc.SessionID},
+ {Label: "created", Value: tc.Timestamp.Format("2006-01-02 15:04:05")},
+ }
+ sb.WriteString(styles.renderIdentity(label, "", rows))
+
+ intent := extractIntent(nil, sessionPrompt)
+ hint := "Not generated. Temporary checkpoints can be summarized after commit. Run `trace explain --generate` on the resulting commit."
+ sb.WriteString(renderExplainBody(w, buildNoSummaryMarkdown(intent, nil, hint)))
+
+ // Transcript section: full shows trace session, verbose shows checkpoint scope
+ // For temporary checkpoints, load transcript and compute scope from parent commit
+ var fullTranscript []byte
+ var scopedTranscript []byte
+ if full || verbose {
+ fullTranscript, _ = store.GetTranscriptFromCommit(ctx, tc.CommitHash, tc.MetadataDir, agentType) //nolint:errcheck // Best-effort
+
+ if verbose && len(fullTranscript) > 0 {
+ // Compute scoped transcript by finding where parent's transcript ended
+ // Each shadow branch commit has the full transcript up to that point,
+ // so we diff against parent to get just this checkpoint's activity
+ scopedTranscript = fullTranscript // Default to full if no parent
+ if shadowCommit.NumParents() > 0 {
+ if parent, parentErr := shadowCommit.Parent(0); parentErr == nil {
+ parentTranscript, _ := store.GetTranscriptFromCommit(ctx, parent.Hash, tc.MetadataDir, agentType) //nolint:errcheck // Best-effort
+ if len(parentTranscript) > 0 {
+ parentOffset := transcriptOffset(parentTranscript, agentType)
+ scopedTranscript = scopeTranscriptForCheckpoint(fullTranscript, parentOffset, agentType)
+ }
+ }
+ }
+ }
+ }
+ if verbose || full {
+ label := "Transcript (checkpoint scope)"
+ if full {
+ label = "Transcript (full session)"
+ }
+ sb.WriteString("\n")
+ sb.WriteString(styles.sectionRule(label, styles.width))
+ sb.WriteString("\n")
+ }
+ appendTranscriptSection(&sb, verbose, full, fullTranscript, scopedTranscript, sessionPrompt, agentType)
+
+ return sb.String(), true, nil
+}
+
+// getAssociatedCommits finds git commits that reference the given checkpoint ID.
+// Searches commits on the current branch for Trace-Checkpoint trailer matches.
+// When searchAll is true, uses full DAG walk with no depth limit (may be slow).
+// This finds checkpoint commits on merged feature branches (second parents of merges).
+func getAssociatedCommits(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID, searchAll bool) ([]associatedCommit, error) {
+ head, err := repo.Head()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get HEAD: %w", err)
+ }
+
+ commits := []associatedCommit{} // Initialize as empty slice, not nil (nil means "not searched")
+ targetID := checkpointID.String()
+
+ collectCommit := func(c *object.Commit) {
+ fullSHA := c.Hash.String()
+ shortSHA := fullSHA
+ if len(fullSHA) >= 7 {
+ shortSHA = fullSHA[:7]
+ }
+ commits = append(commits, associatedCommit{
+ SHA: fullSHA,
+ ShortSHA: shortSHA,
+ Message: strings.Split(c.Message, "\n")[0],
+ Author: c.Author.Name,
+ Email: c.Author.Email,
+ Date: c.Author.When,
+ })
+ }
+
+ if searchAll {
+ // Full DAG walk: follows all parents of merge commits, no depth limit.
+ // This finds checkpoint commits on merged feature branches.
+ iter, iterErr := repo.Log(&git.LogOptions{
+ From: head.Hash(),
+ Order: git.LogOrderCommitterTime,
+ })
+ if iterErr != nil {
+ return nil, fmt.Errorf("failed to get commit log: %w", iterErr)
+ }
+ defer iter.Close()
+
+ err = iter.ForEach(func(c *object.Commit) error {
+ if err := ctx.Err(); err != nil {
+ return err //nolint:wrapcheck // Propagating context cancellation
+ }
+ cpID, found := trailers.ParseCheckpoint(c.Message)
+ if found && cpID.String() == targetID {
+ collectCommit(c)
+ }
+ return nil
+ })
+ } else {
+ // First-parent walk with depth limit and branch filtering.
+ // Avoids walking into main's history through merge commit parents.
+ reachableFromMain := computeReachableFromMain(ctx, repo)
+
+ err = walkFirstParentCommits(ctx, repo, head.Hash(), commitScanLimit, func(c *object.Commit) error {
+ // Once we hit a commit reachable from main on the first-parent chain,
+ // all earlier ancestors are also shared-with-main, so stop scanning.
+ if reachableFromMain[c.Hash] {
+ return errStopIteration
+ }
+
+ cpID, found := trailers.ParseCheckpoint(c.Message)
+ if found && cpID.String() == targetID {
+ collectCommit(c)
+ }
+ return nil
+ })
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("error iterating commits: %w", err)
+ }
+
+ return commits, nil
+}
+
+// scopeTranscriptForCheckpoint slices a transcript to include only the portion
+// relevant to a specific checkpoint, starting from the given offset.
+// For Claude Code (JSONL), the offset is a line number and we slice by line.
+// For Gemini (single JSON blob), the offset is a message index and we slice by message.
+func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentType types.AgentType) []byte {
+ switch agentType {
+ case agent.AgentTypeGemini:
+ scoped, err := geminicli.SliceFromMessage(fullTranscript, startOffset)
+ if err != nil {
+ return nil
+ }
+ return scoped
+ case agent.AgentTypeOpenCode:
+ scoped, err := opencode.SliceFromMessage(fullTranscript, startOffset)
+ if err != nil {
+ return nil
+ }
+ return scoped
+ case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
+ return transcript.SliceFromLine(fullTranscript, startOffset)
+ }
+ return transcript.SliceFromLine(fullTranscript, startOffset)
+}
+
+// extractPromptsFromTranscript extracts user prompts from transcript bytes.
+// Returns a slice of prompt strings.
+func extractPromptsFromTranscript(transcriptBytes []byte, agentType types.AgentType) []string {
+ if len(transcriptBytes) == 0 {
+ return nil
+ }
+
+ // transcriptBytes is read from checkpoint storage, which redacts on write.
+ condensed, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
+ if err != nil || len(condensed) == 0 {
+ condensed, err = buildCondensedCompactTranscriptEntries(transcriptBytes)
+ }
+ if err != nil || len(condensed) == 0 {
+ return nil
+ }
+
+ var prompts []string
+ for _, entry := range condensed {
+ if entry.Type == summarize.EntryTypeUser && entry.Content != "" {
+ prompts = append(prompts, entry.Content)
+ }
+ }
+ return prompts
+}
+
+// extractIntent picks the user-facing intent line from available prompt sources.
+// Preference: first non-empty entry of scopedPrompts, then first non-empty line
+// of fallbackPrompts, then "". Truncates to maxIntentDisplayLength.
+func extractIntent(scopedPrompts []string, fallbackPrompts string) string {
+ for _, p := range scopedPrompts {
+ if p == "" {
+ continue
+ }
+ return strategy.TruncateDescription(p, maxIntentDisplayLength)
+ }
+ for _, line := range strings.Split(fallbackPrompts, "\n") {
+ if line == "" {
+ continue
+ }
+ return strategy.TruncateDescription(line, maxIntentDisplayLength)
+ }
+ return ""
+}
+
+// buildNoSummaryMarkdown renders the body for a checkpoint that does not yet
+// have an AI summary. It mirrors the `## Intent` / `## Summary` / `## Files`
+// shape of the generated case so the brand markdown renderer can take the same
+// path. The italic *summary* paragraph is the affordance pointing the user at
+// `--generate` (or, for temporary checkpoints, at committing first).
+func buildNoSummaryMarkdown(intent string, files []string, summaryHint string) string {
+ var sb strings.Builder
+
+ sb.WriteString("## Intent\n\n")
+ if intent == "" {
+ sb.WriteString("*(no prompt recorded)*\n\n")
+ } else {
+ fmt.Fprintf(&sb, "%s\n\n", escapeSummaryText(intent))
+ }
+
+ fmt.Fprintf(&sb, "## Summary\n\n*%s*\n", escapeSummaryText(summaryHint))
+
+ if len(files) > 0 {
+ fmt.Fprintf(&sb, "\n## Files (%d)\n\n", len(files))
+ for _, f := range files {
+ fmt.Fprintf(&sb, "- `%s`\n", escapeInlineCodeText(f))
+ }
+ }
+
+ return sb.String()
+}
+
+// ambiguousMatch describes one match in an ambiguous-prefix failure.
+// SessionID is optional and only set for temporary-checkpoint matches.
+type ambiguousMatch struct {
+ ShortID string
+ Timestamp time.Time
+ SessionID string
+}
diff --git a/cli/explain_2_test.go b/cli/explain_2_test.go
new file mode 100644
index 0000000..2628aca
--- /dev/null
+++ b/cli/explain_2_test.go
@@ -0,0 +1,826 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/summarize"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMaybeCompactExternalTranscriptForSummary_RedactsExternalOutput(t *testing.T) {
+ // Cannot use t.Parallel() because external agent discovery mutates the
+ // package-level agent registry and this test changes cwd/PATH.
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ t.Chdir(tmpDir)
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled":true,"external_agents":true}`),
+ 0o644,
+ ))
+
+ const (
+ name = "summary-redact"
+ kind = types.AgentType("Summary Redact Agent")
+ secret = "q9Xv2Lm8Rt1Yp4Kd7Wz0Hs6Nc3Bf5Jg"
+ )
+ externalDir := t.TempDir()
+ script := `#!/bin/sh
+case "$1" in
+ info)
+ echo '{"protocol_version":1,"name":"` + name + `","type":"` + string(kind) + `","description":"External redaction test agent","is_preview":false,"protected_dirs":[],"hook_names":[],"capabilities":{"hooks":false,"transcript_analyzer":false,"transcript_preparer":false,"token_calculator":false,"compact_transcript":true,"text_generator":false,"hook_response_writer":false,"subagent_aware_extractor":false}}'
+ ;;
+ compact-transcript)
+ echo '{"transcript":"eyJ2IjoxLCJhZ2VudCI6InN1bW1hcnktcmVkYWN0IiwiY2xpX3ZlcnNpb24iOiJ0ZXN0IiwidHlwZSI6InVzZXIiLCJ0cyI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwiY29udGVudCI6W3sidGV4dCI6ImtleT1xOVh2MkxtOFJ0MVlwNEtkN1d6MEhzNk5jM0JmNUpnIn1dfQo="}'
+ ;;
+ *)
+ echo '{}'
+ ;;
+esac
+`
+ require.NoError(t, os.WriteFile(filepath.Join(externalDir, "trace-agent-"+name), []byte(script), 0o755))
+ t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ got := maybeCompactExternalTranscriptForSummary(ctx, []byte("not-json"), kind)
+ if strings.Contains(string(got), secret) {
+ t.Fatalf("external compact transcript was not redacted: %s", got)
+ }
+ if !strings.Contains(string(got), redact.RedactedPlaceholder) {
+ t.Fatalf("expected redacted compact transcript, got: %s", got)
+ }
+}
+
+func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testing.T) {
+ tmpTimeout := checkpointSummaryTimeout
+ tmpGenerator := generateTranscriptSummary
+ t.Cleanup(func() {
+ checkpointSummaryTimeout = tmpTimeout
+ generateTranscriptSummary = tmpGenerator
+ })
+
+ checkpointSummaryTimeout = 30 * time.Second
+
+ parentCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ parentDeadline, _ := parentCtx.Deadline()
+
+ var gotDeadline time.Time
+ generateTranscriptSummary = func(
+ ctx context.Context,
+ _ redact.RedactedBytes,
+ _ []string,
+ _ types.AgentType,
+ _ summarize.Generator,
+ ) (*checkpoint.Summary, error) {
+ gotDeadline, _ = ctx.Deadline()
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+
+ _, appliedDeadline, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected DeadlineExceeded, got %v", err)
+ }
+ if gotDeadline.IsZero() {
+ t.Fatal("expected deadline to be captured")
+ }
+ // The applied deadline must reflect the shorter parent-ctx deadline,
+ // not the package-default checkpointSummaryTimeout. Otherwise
+ // formatCheckpointSummaryError would report the wrong timeout to users.
+ if appliedDeadline >= checkpointSummaryTimeout {
+ t.Fatalf("appliedDeadline = %s; want shorter than %s (parent had tighter deadline)",
+ appliedDeadline, checkpointSummaryTimeout)
+ }
+ if delta := gotDeadline.Sub(parentDeadline); delta < -5*time.Millisecond || delta > 5*time.Millisecond {
+ t.Fatalf("deadline delta = %s, want near 0", delta)
+ }
+ if strings.Contains(err.Error(), "30s") {
+ t.Fatalf("timeout error should not report default timeout when parent deadline fired: %v", err)
+ }
+}
+
+// TestGenerateCheckpointAISummary_PreservesClaudeErrorWhenCtxIsDone guards
+// against the race where the underlying summarizer returns a typed
+// *ClaudeError AND the context happens to be done. Prior code checked
+// timeoutCtx.Err() and unconditionally wrapped with %w context.DeadlineExceeded,
+// which discarded the typed error and routed the user to the wrong
+// "safety deadline" guidance instead of the auth/rate-limit message.
+func TestGenerateCheckpointAISummary_PreservesClaudeErrorWhenCtxIsDone(t *testing.T) {
+ tmpTimeout := checkpointSummaryTimeout
+ tmpGenerator := generateTranscriptSummary
+ t.Cleanup(func() {
+ checkpointSummaryTimeout = tmpTimeout
+ generateTranscriptSummary = tmpGenerator
+ })
+
+ checkpointSummaryTimeout = 30 * time.Second
+
+ // Cancel the parent before we even call — ctx.Err() will be non-nil.
+ parentCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ claudeErr := &claudecode.ClaudeError{Kind: claudecode.ClaudeErrorAuth, Message: "Invalid API key"}
+ generateTranscriptSummary = func(
+ context.Context,
+ redact.RedactedBytes,
+ []string,
+ types.AgentType,
+ summarize.Generator,
+ ) (*checkpoint.Summary, error) {
+ return nil, claudeErr
+ }
+
+ _, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
+ var ce *claudecode.ClaudeError
+ if !errors.As(err, &ce) {
+ t.Fatalf("errors.As did not recover *ClaudeError; got %v", err)
+ }
+ if ce.Kind != claudecode.ClaudeErrorAuth {
+ t.Errorf("Kind = %v; want auth", ce.Kind)
+ }
+}
+
+func TestGenerateCheckpointAISummary_ClampsLongParentDeadlineToDefaultTimeout(t *testing.T) {
+ tmpTimeout := checkpointSummaryTimeout
+ tmpGenerator := generateTranscriptSummary
+ t.Cleanup(func() {
+ checkpointSummaryTimeout = tmpTimeout
+ generateTranscriptSummary = tmpGenerator
+ })
+
+ checkpointSummaryTimeout = 50 * time.Millisecond
+
+ parentCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+
+ var gotDeadline time.Time
+ generateTranscriptSummary = func(
+ ctx context.Context,
+ _ redact.RedactedBytes,
+ _ []string,
+ _ types.AgentType,
+ _ summarize.Generator,
+ ) (*checkpoint.Summary, error) {
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ return nil, errors.New("expected deadline on summary context")
+ }
+ gotDeadline = deadline
+ return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil
+ }
+
+ start := time.Now()
+ summary, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
+ if err != nil {
+ t.Fatalf("generateCheckpointAISummary() error = %v", err)
+ }
+ if summary == nil {
+ t.Fatal("expected summary")
+ }
+ if gotDeadline.IsZero() {
+ t.Fatal("expected deadline to be set")
+ }
+ if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond {
+ t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout)
+ }
+}
+
+func TestGenerateCheckpointAISummary_UsesCancellationSentinel(t *testing.T) {
+ tmpTimeout := checkpointSummaryTimeout
+ tmpGenerator := generateTranscriptSummary
+ t.Cleanup(func() {
+ checkpointSummaryTimeout = tmpTimeout
+ generateTranscriptSummary = tmpGenerator
+ })
+
+ parentCtx, cancel := context.WithCancel(context.Background())
+
+ generateTranscriptSummary = func(
+ ctx context.Context,
+ _ redact.RedactedBytes,
+ _ []string,
+ _ types.AgentType,
+ _ summarize.Generator,
+ ) (*checkpoint.Summary, error) {
+ cancel()
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+
+ _, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
+ if err == nil {
+ t.Fatal("expected cancellation error")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected Canceled, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "canceled") {
+ t.Fatalf("expected cancellation message, got %v", err)
+ }
+}
+
+func TestExplainCommit_NotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+
+ var stdout bytes.Buffer
+ err := runExplainCommit(context.Background(), &stdout, &stdout, "nonexistent", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error for nonexistent commit, got nil")
+ }
+ if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "resolve") {
+ t.Errorf("expected 'not found' or 'resolve' in error, got: %v", err)
+ }
+}
+
+func TestExplainCommit_NoTraceData(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create a commit without Trace metadata
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitHash, err := w.Commit("regular commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ err = runExplainCommit(context.Background(), &stdout, &stdout, commitHash.String(), false, false, false, false, false, false, false)
+ if err != nil {
+ t.Fatalf("runExplainCommit() should not error for non-Trace commits, got: %v", err)
+ }
+
+ output := stdout.String()
+
+ // Should show message indicating no Trace checkpoint (new failure-block shape)
+ if !strings.Contains(output, "✗ No associated Trace checkpoint") {
+ t.Errorf("expected styled failure block on output, got: %s", output)
+ }
+ if !strings.Contains(output, " reason") {
+ t.Errorf("expected reason row, got: %s", output)
+ }
+ // Should mention the commit hash
+ if !strings.Contains(output, commitHash.String()[:7]) {
+ t.Errorf("expected output to contain short commit hash, got: %s", output)
+ }
+}
+
+func TestExplainCommit_WithMetadataTrailerButNoCheckpoint(t *testing.T) {
+ // Test that commits with Trace-Metadata trailer (but no Trace-Checkpoint)
+ // now show "no checkpoint" message (new behavior)
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create session metadata directory first
+ sessionID := "2025-12-09-test-session-xyz789"
+ sessionDir := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
+ if err := os.MkdirAll(sessionDir, 0o750); err != nil {
+ t.Fatalf("failed to create session dir: %v", err)
+ }
+
+ // Create prompt file
+ promptContent := "Add new feature"
+ if err := os.WriteFile(filepath.Join(sessionDir, paths.PromptFileName), []byte(promptContent), 0o644); err != nil {
+ t.Fatalf("failed to create prompt file: %v", err)
+ }
+
+ // Create a commit with Trace-Metadata trailer (but NO Trace-Checkpoint)
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("feature content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+
+ // Commit with Trace-Metadata trailer (no Trace-Checkpoint)
+ metadataDir := ".trace/metadata/" + sessionID
+ commitMessage := trailers.FormatMetadata("Add new feature", metadataDir)
+ commitHash, err := w.Commit(commitMessage, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ err = runExplainCommit(context.Background(), &stdout, &stdout, commitHash.String(), false, false, false, false, false, false, false)
+ if err != nil {
+ t.Fatalf("runExplainCommit() error = %v", err)
+ }
+
+ output := stdout.String()
+
+ // New behavior: should show "no checkpoint" failure block since there's no Trace-Checkpoint trailer
+ if !strings.Contains(output, "✗ No associated Trace checkpoint") {
+ t.Errorf("expected styled failure block, got: %s", output)
+ }
+ if !strings.Contains(output, " reason") {
+ t.Errorf("expected reason row, got: %s", output)
+ }
+ // Should mention the commit hash
+ if !strings.Contains(output, commitHash.String()[:7]) {
+ t.Errorf("expected output to contain short commit hash, got: %s", output)
+ }
+}
+
+func TestExplainDefault_ShowsBranchView(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ // Create initial commit so HEAD exists (required for branch view)
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ err = runExplainDefault(context.Background(), &stdout, true) // noPager=true for test
+ // Should NOT error - should show branch view
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+
+ output := stdout.String()
+ // Should show branch header (new metadata-row shape: "branch ")
+ if !strings.Contains(output, "branch ") {
+ t.Errorf("expected 'branch' row in output, got: %s", output)
+ }
+ // Should show checkpoints count (likely 0)
+ if !strings.Contains(output, "checkpoints") {
+ t.Errorf("expected 'checkpoints' row in output, got: %s", output)
+ }
+}
+
+func TestExplainDefault_NoCheckpoints_ShowsHelpfulMessage(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ // Create initial commit so HEAD exists (required for branch view)
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create .trace directory but no checkpoints
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ err = runExplainDefault(context.Background(), &stdout, true) // noPager=true for test
+ // Should NOT error
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+
+ output := stdout.String()
+ // Should show checkpoints count as 0 (new metadata-row shape)
+ if !strings.Contains(output, "checkpoints 0") {
+ t.Errorf("expected 'checkpoints 0' in output, got: %s", output)
+ }
+ // Should show helpful message about checkpoints appearing after saves
+ if !strings.Contains(output, "Checkpoints will appear") || !strings.Contains(output, "agent session") {
+ t.Errorf("expected helpful message about checkpoints, got: %s", output)
+ }
+}
+
+func TestExplainBothFlagsError(t *testing.T) {
+ // Test that providing both --session and --commit returns an error
+ var stdout, stderr bytes.Buffer
+ err := runExplain(context.Background(), &stdout, &stderr, "session-id", "commit-sha", "", "", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error when both flags provided, got nil")
+ }
+ // Case-insensitive check for "cannot specify multiple"
+ errLower := strings.ToLower(err.Error())
+ if !strings.Contains(errLower, "cannot specify multiple") {
+ t.Errorf("expected 'cannot specify multiple' in error, got: %v", err)
+ }
+}
+
+func TestFormatSessionInfo(t *testing.T) {
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-09-test-session-abc",
+ Description: "Test description",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{
+ {
+ CheckpointID: "abc1234567890",
+ Message: "First checkpoint",
+ Timestamp: now.Add(-time.Hour),
+ },
+ {
+ CheckpointID: "def0987654321",
+ Message: "Second checkpoint",
+ Timestamp: now,
+ },
+ },
+ }
+
+ // Create checkpoint details matching the session checkpoints
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "abc1234",
+ Timestamp: now.Add(-time.Hour),
+ Message: "First checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Fix the bug",
+ Responses: []string{"Fixed the bug in auth module"},
+ Files: []string{"auth.go"},
+ }},
+ Files: []string{"auth.go"},
+ },
+ {
+ Index: 2,
+ ShortID: "def0987",
+ Timestamp: now,
+ Message: "Second checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Add tests",
+ Responses: []string{"Added unit tests"},
+ Files: []string{"auth_test.go"},
+ }},
+ Files: []string{"auth_test.go"},
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Verify output contains expected sections
+ if !strings.Contains(output, "Session:") {
+ t.Error("expected output to contain 'Session:'")
+ }
+ if !strings.Contains(output, session.ID) {
+ t.Error("expected output to contain session ID")
+ }
+ if !strings.Contains(output, "Strategy:") {
+ t.Error("expected output to contain 'Strategy:'")
+ }
+ if !strings.Contains(output, "manual-commit") {
+ t.Error("expected output to contain strategy name")
+ }
+ if !strings.Contains(output, "Checkpoints: 2") {
+ t.Error("expected output to contain 'Checkpoints: 2'")
+ }
+ // Check checkpoint details
+ if !strings.Contains(output, "Checkpoint 1") {
+ t.Error("expected output to contain 'Checkpoint 1'")
+ }
+ if !strings.Contains(output, "## Prompt") {
+ t.Error("expected output to contain '## Prompt'")
+ }
+ if !strings.Contains(output, "## Responses") {
+ t.Error("expected output to contain '## Responses'")
+ }
+ if !strings.Contains(output, "Files Modified") {
+ t.Error("expected output to contain 'Files Modified'")
+ }
+}
+
+func TestFormatSessionInfo_WithSourceRef(t *testing.T) {
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-09-test-session-abc",
+ Description: "Test description",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{
+ {
+ CheckpointID: "abc1234567890",
+ Message: "First checkpoint",
+ Timestamp: now,
+ },
+ },
+ }
+
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "abc1234",
+ Timestamp: now,
+ Message: "First checkpoint",
+ },
+ }
+
+ // Test with source ref provided
+ sourceRef := "trace/metadata@abc123def456"
+ output := formatSessionInfo(session, sourceRef, checkpointDetails)
+
+ // Verify source ref is displayed
+ if !strings.Contains(output, "Source Ref:") {
+ t.Error("expected output to contain 'Source Ref:'")
+ }
+ if !strings.Contains(output, sourceRef) {
+ t.Errorf("expected output to contain source ref %q, got:\n%s", sourceRef, output)
+ }
+}
+
+// TestManualCommitStrategyCallable verifies that the strategy's methods are callable
+func TestManualCommitStrategyCallable(t *testing.T) {
+ s := strategy.NewManualCommitStrategy()
+
+ // GetAdditionalSessions should exist and be callable
+ _, err := s.GetAdditionalSessions(context.Background())
+ if err != nil {
+ t.Logf("GetAdditionalSessions returned error: %v", err)
+ }
+}
+
+func TestFormatSessionInfo_CheckpointNumberingReversed(t *testing.T) {
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-09-test-session",
+ Strategy: "manual-commit",
+ StartTime: now.Add(-2 * time.Hour),
+ Checkpoints: []strategy.Checkpoint{}, // Not used for format test
+ }
+
+ // Simulate checkpoints coming in newest-first order from ListSessions
+ // but numbered with oldest=1, newest=N
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 3, // Newest checkpoint should have highest number
+ ShortID: "ccc3333",
+ Timestamp: now,
+ Message: "Third (newest) checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Latest change",
+ Responses: []string{},
+ }},
+ },
+ {
+ Index: 2,
+ ShortID: "bbb2222",
+ Timestamp: now.Add(-time.Hour),
+ Message: "Second checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Middle change",
+ Responses: []string{},
+ }},
+ },
+ {
+ Index: 1, // Oldest checkpoint should be #1
+ ShortID: "aaa1111",
+ Timestamp: now.Add(-2 * time.Hour),
+ Message: "First (oldest) checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Initial change",
+ Responses: []string{},
+ }},
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Verify checkpoint ordering in output
+ // Checkpoint 3 should appear before Checkpoint 2 which should appear before Checkpoint 1
+ idx3 := strings.Index(output, "Checkpoint 3")
+ idx2 := strings.Index(output, "Checkpoint 2")
+ idx1 := strings.Index(output, "Checkpoint 1")
+
+ if idx3 == -1 || idx2 == -1 || idx1 == -1 {
+ t.Fatalf("expected all checkpoints to be in output, got:\n%s", output)
+ }
+
+ // In the output, they should appear in the order they're in the slice (newest first)
+ if idx3 > idx2 || idx2 > idx1 {
+ t.Errorf("expected checkpoints to appear in order 3, 2, 1 in output (newest first), got positions: 3=%d, 2=%d, 1=%d", idx3, idx2, idx1)
+ }
+
+ // Verify the dates appear correctly
+ if !strings.Contains(output, "Latest change") {
+ t.Error("expected output to contain 'Latest change' prompt")
+ }
+ if !strings.Contains(output, "Initial change") {
+ t.Error("expected output to contain 'Initial change' prompt")
+ }
+}
+
+func TestFormatSessionInfo_EmptyCheckpoints(t *testing.T) {
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-09-empty-session",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ output := formatSessionInfo(session, "", nil)
+
+ if !strings.Contains(output, "Checkpoints: 0") {
+ t.Errorf("expected output to contain 'Checkpoints: 0', got:\n%s", output)
+ }
+}
+
+func TestFormatSessionInfo_CheckpointWithTaskMarker(t *testing.T) {
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-09-task-session",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "abc1234",
+ Timestamp: now,
+ IsTaskCheckpoint: true,
+ Message: "Task checkpoint",
+ Interactions: []interaction{{
+ Prompt: "Run tests",
+ Responses: []string{},
+ }},
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ if !strings.Contains(output, "[Task]") {
+ t.Errorf("expected output to contain '[Task]' marker, got:\n%s", output)
+ }
+}
+
+func TestFormatSessionInfo_CheckpointWithDate(t *testing.T) {
+ // Test that checkpoint headers include the full date
+ timestamp := time.Date(2025, 12, 10, 14, 35, 0, 0, time.UTC)
+ session := &strategy.Session{
+ ID: "2025-12-10-dated-session",
+ Strategy: "manual-commit",
+ StartTime: timestamp,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "abc1234",
+ Timestamp: timestamp,
+ Message: "Test checkpoint",
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Should contain "2025-12-10 14:35" in the checkpoint header
+ if !strings.Contains(output, "2025-12-10 14:35") {
+ t.Errorf("expected output to contain date '2025-12-10 14:35', got:\n%s", output)
+ }
+}
+
+func TestFormatSessionInfo_ShowsMessageWhenNoInteractions(t *testing.T) {
+ // Test that checkpoints without transcript content show the commit message
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-12-incremental-session",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ // Checkpoint with message but no interactions (like incremental checkpoints)
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "abc1234",
+ Timestamp: now,
+ IsTaskCheckpoint: true,
+ Message: "Starting 'dev' agent: Implement feature X (toolu_01ABC)",
+ Interactions: []interaction{}, // Empty - no transcript available
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Should show the commit message when there are no interactions
+ if !strings.Contains(output, "Starting 'dev' agent: Implement feature X (toolu_01ABC)") {
+ t.Errorf("expected output to contain commit message when no interactions, got:\n%s", output)
+ }
+
+ // Should NOT show "## Prompt" or "## Responses" sections since there are no interactions
+ if strings.Contains(output, "## Prompt") {
+ t.Errorf("expected output to NOT contain '## Prompt' when no interactions, got:\n%s", output)
+ }
+ if strings.Contains(output, "## Responses") {
+ t.Errorf("expected output to NOT contain '## Responses' when no interactions, got:\n%s", output)
+ }
+}
diff --git a/cli/explain_3.go b/cli/explain_3.go
new file mode 100644
index 0000000..0d485e1
--- /dev/null
+++ b/cli/explain_3.go
@@ -0,0 +1,826 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "sort"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/summarize"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ transcriptcompact "github.com/GrayCodeAI/trace/cli/transcript/compact"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "charm.land/lipgloss/v2"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/go-git/go-git/v6/plumbing/storer"
+)
+
+// renderAmbiguousPrefixFailure prints a styled failure block describing an
+// ambiguous prefix. kind is a noun phrase like "commits" or "temporary
+// checkpoints" used in the "matches N " header row.
+func renderAmbiguousPrefixFailure(errW io.Writer, prefix, kind string, matches []ambiguousMatch) {
+ styles := newStatusStyles(errW)
+ rows := []explainRow{
+ {Label: "matches", Value: fmt.Sprintf("%d %s", len(matches), kind)},
+ }
+ for _, m := range matches {
+ ts := ""
+ if !m.Timestamp.IsZero() {
+ ts = " " + m.Timestamp.Format("2006-01-02 15:04:05")
+ }
+ sess := ""
+ if m.SessionID != "" {
+ sess = " session " + m.SessionID
+ }
+ rows = append(rows, explainRow{Label: "", Value: "• " + m.ShortID + ts + sess})
+ }
+ rows = append(rows, explainRow{Label: "hint", Value: "use a longer prefix or a full SHA"})
+ label := fmt.Sprintf("Ambiguous checkpoint prefix %q", prefix)
+ fmt.Fprint(errW, styles.renderFailure(label, rows))
+}
+
+// renderExplainFailure prints a styled failure block to errW and returns the
+// error wrapped as *SilentError so main.go does not double-print. Used at
+// every explain call site that has a friendly, structured error to surface.
+func renderExplainFailure(errW io.Writer, label string, rows []explainRow, structured error) error {
+ fmt.Fprint(errW, newStatusStyles(errW).renderFailure(label, rows))
+ return NewSilentError(structured)
+}
+
+// buildAmbiguousCommitMatches converts a slice of plumbing.Hash matches
+// (from resolveCommitUnambiguous) into ambiguousMatch entries with
+// abbreviated short IDs and author timestamps. Caps at 5 entries to keep
+// the failure block readable when a short prefix collides on many
+// commits.
+func buildAmbiguousCommitMatches(repo *git.Repository, hashes []plumbing.Hash) []ambiguousMatch {
+ const maxMatches = 5
+ matches := make([]ambiguousMatch, 0, len(hashes))
+ for i, h := range hashes {
+ if i >= maxMatches {
+ break
+ }
+ m := ambiguousMatch{ShortID: abbreviateCommitHash(repo, h)}
+ if commit, err := repo.CommitObject(h); err == nil {
+ m.Timestamp = commit.Author.When
+ }
+ matches = append(matches, m)
+ }
+ return matches
+}
+
+// buildAmbiguousCheckpointMatches converts a slice of CheckpointID matches
+// into ambiguousMatch entries enriched with timestamps and session IDs from
+// the loaded committed-checkpoint listing. Caps at 5 entries to keep the
+// failure block readable when a short prefix collides on many checkpoints.
+func buildAmbiguousCheckpointMatches(ids []id.CheckpointID, committed []checkpoint.CommittedInfo) []ambiguousMatch {
+ const maxMatches = 5
+ infoByID := make(map[id.CheckpointID]checkpoint.CommittedInfo, len(committed))
+ for _, info := range committed {
+ infoByID[info.CheckpointID] = info
+ }
+ matches := make([]ambiguousMatch, 0, len(ids))
+ for i, cpID := range ids {
+ if i >= maxMatches {
+ break
+ }
+ m := ambiguousMatch{ShortID: cpID.String()}
+ if info, ok := infoByID[cpID]; ok {
+ m.Timestamp = info.CreatedAt
+ m.SessionID = info.SessionID
+ }
+ matches = append(matches, m)
+ }
+ return matches
+}
+
+// renderExplainBody routes a markdown body through the brand renderer when
+// the writer supports color, and returns the markdown source verbatim
+// otherwise. Single point of policy for every explain body section.
+func renderExplainBody(w io.Writer, md string) string {
+ if !shouldUseColor(w) {
+ return md
+ }
+ rendered, err := defaultRenderTerminalMarkdown(w, md)
+ if err != nil {
+ logging.Debug(context.Background(), "explain markdown render failed", slog.String("error", err.Error()))
+ return md
+ }
+ return rendered
+}
+
+// formatCheckpointOutput formats checkpoint data based on verbosity level.
+// When verbose is false: summary only (ID, session, timestamp, tokens, intent).
+// When verbose is true: adds files, associated commits, and scoped transcript for this checkpoint.
+// When full is true: shows parsed full session transcript instead of scoped transcript.
+//
+// Transcript scope is controlled by CheckpointTranscriptStart in metadata, which indicates
+// where this checkpoint's content begins in the full session transcript.
+//
+// Author is displayed when available (only for committed checkpoints).
+// Associated commits are git commits that reference this checkpoint via Trace-Checkpoint trailer.
+func formatCheckpointOutput(summary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, checkpointID id.CheckpointID, associatedCommits []associatedCommit, author checkpoint.Author, verbose, full bool, w io.Writer) string {
+ var sb strings.Builder
+ meta := content.Metadata
+ styles := newStatusStyles(w)
+
+ // Scope the transcript to this checkpoint's portion
+ // If CheckpointTranscriptStart > 0, we slice the transcript to only include
+ // content from that point onwards (excluding earlier checkpoint content)
+ scopedTranscript := scopeTranscriptForCheckpoint(content.Transcript, meta.GetTranscriptStart(), meta.Agent)
+
+ // Extract prompts from the scoped transcript for intent extraction
+ scopedPrompts := extractPromptsFromTranscript(scopedTranscript, meta.Agent)
+
+ sb.WriteString(formatCheckpointHeader(summary, meta, checkpointID, associatedCommits, author, styles))
+ sb.WriteString(styles.horizontalRule(styles.width))
+ sb.WriteString("\n")
+
+ if meta.Summary != nil {
+ md := buildSummaryMarkdown(meta.Summary)
+ if verbose || full {
+ md += buildFilesMarkdown(meta.FilesTouched)
+ }
+ if shouldUseColor(w) {
+ rendered, err := defaultRenderTerminalMarkdown(w, md)
+ if err != nil {
+ logging.Debug(context.Background(), "explain markdown render failed", slog.String("error", err.Error()))
+ sb.WriteString(md)
+ } else {
+ sb.WriteString(rendered)
+ }
+ } else {
+ sb.WriteString(md)
+ }
+ } else {
+ intent := extractIntent(scopedPrompts, content.Prompts)
+
+ var files []string
+ if verbose || full {
+ files = meta.FilesTouched
+ }
+
+ hint := fmt.Sprintf("Not generated yet. Run `trace explain --generate %s` to create an AI summary.", checkpointID)
+ md := buildNoSummaryMarkdown(intent, files, hint)
+ sb.WriteString(renderExplainBody(w, md))
+ }
+
+ if verbose || full {
+ label := "Transcript (checkpoint scope)"
+ if full {
+ label = "Transcript (full session)"
+ }
+ sb.WriteString("\n")
+ sb.WriteString(styles.sectionRule(label, styles.width))
+ sb.WriteString("\n")
+ appendTranscriptSection(&sb, verbose, full, content.Transcript, scopedTranscript, content.Prompts, meta.Agent)
+ }
+
+ return sb.String()
+}
+
+// appendTranscriptSection appends the appropriate transcript section to the builder
+// based on verbosity level. Full mode shows the trace session, verbose shows checkpoint scope.
+// fullTranscript is the trace session transcript, scopedContent is either scoped transcript bytes
+// or a pre-formatted string (for backwards compat), and scopedFallback is used when scoped parsing fails.
+func appendTranscriptSection(sb *strings.Builder, verbose, full bool, fullTranscript, scopedTranscript []byte, scopedFallback string, agentType types.AgentType) {
+ switch {
+ case full:
+ sb.WriteString(formatTranscriptBytes(fullTranscript, "", agentType))
+
+ case verbose:
+ sb.WriteString(formatTranscriptBytes(scopedTranscript, scopedFallback, agentType))
+ }
+}
+
+// formatTranscriptBytes formats transcript bytes into a human-readable string.
+// It parses the transcript (JSONL for Claude, JSON for Gemini) and formats it using the condensed format.
+// The fallback is used for backwards compatibility when transcript parsing fails or is empty.
+func formatTranscriptBytes(transcriptBytes []byte, fallback string, agentType types.AgentType) string {
+ if len(transcriptBytes) == 0 {
+ if fallback != "" {
+ return fallback + "\n"
+ }
+ return " (none)\n"
+ }
+
+ // transcriptBytes is read from checkpoint storage, which redacts on write.
+ condensed, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(transcriptBytes), agentType)
+ if err != nil || len(condensed) == 0 {
+ condensed, err = buildCondensedCompactTranscriptEntries(transcriptBytes)
+ }
+ if err != nil || len(condensed) == 0 {
+ if fallback != "" {
+ return fallback + "\n"
+ }
+ return " (failed to parse transcript)\n"
+ }
+
+ input := summarize.Input{Transcript: condensed}
+ return summarize.FormatCondensedTranscript(input)
+}
+
+func buildCondensedCompactTranscriptEntries(transcriptBytes []byte) ([]summarize.Entry, error) {
+ compactEntries, err := transcriptcompact.BuildCondensedEntries(transcriptBytes)
+ if err != nil {
+ return nil, fmt.Errorf("parsing compact transcript: %w", err)
+ }
+
+ entries := make([]summarize.Entry, 0, len(compactEntries))
+ for _, entry := range compactEntries {
+ switch entry.Type {
+ case "user":
+ entries = append(entries, summarize.Entry{Type: summarize.EntryTypeUser, Content: entry.Content})
+ case "assistant":
+ entries = append(entries, summarize.Entry{Type: summarize.EntryTypeAssistant, Content: entry.Content})
+ case "tool": //nolint:goconst // semantic label, not worth a constant
+ entries = append(entries, summarize.Entry{Type: summarize.EntryTypeTool, ToolName: entry.ToolName, ToolDetail: entry.ToolDetail})
+ }
+ }
+
+ if len(entries) == 0 {
+ return nil, errors.New("no parseable compact transcript entries")
+ }
+
+ return entries, nil
+}
+
+// formatCheckpointHeader builds the metadata block above the summary body.
+// When color is enabled, values are styled with the shared status palette;
+// otherwise the same compact shape is returned as plain text.
+func formatCheckpointHeader(
+ summary *checkpoint.CheckpointSummary,
+ meta checkpoint.CommittedMetadata,
+ cpID id.CheckpointID,
+ commits []associatedCommit,
+ author checkpoint.Author,
+ styles statusStyles,
+) string {
+ var sb strings.Builder
+
+ headline := "● Checkpoint " + cpID.String()
+ if styles.colorEnabled {
+ bullet := styles.render(lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")), "●")
+ key := styles.render(styles.bold, "Checkpoint")
+ val := styles.render(lipgloss.NewStyle().Foreground(lipgloss.Color("#fb923c")), cpID.String())
+ headline = bullet + " " + key + " " + val
+ }
+ sb.WriteString(headline)
+ sb.WriteString("\n")
+
+ writeRow := func(label, value string) {
+ paddedLabel := fmt.Sprintf("%-9s", label)
+ if styles.colorEnabled {
+ paddedLabel = styles.render(styles.dim, paddedLabel)
+ }
+ fmt.Fprintf(&sb, " %s%s\n", paddedLabel, value)
+ }
+
+ writeRow("session", meta.SessionID)
+ writeRow("created", meta.CreatedAt.Format("2006-01-02 15:04:05"))
+ if author.Name != "" {
+ writeRow("author", fmt.Sprintf("%s <%s>", author.Name, author.Email))
+ }
+
+ tokenUsage := meta.TokenUsage
+ if tokenUsage == nil && summary != nil {
+ tokenUsage = summary.TokenUsage
+ }
+ if tokenUsage != nil {
+ total := tokenUsage.InputTokens + tokenUsage.CacheCreationTokens +
+ tokenUsage.CacheReadTokens + tokenUsage.OutputTokens
+ tokensVal := formatTokenCount(total)
+ if styles.colorEnabled {
+ tokensVal = styles.render(styles.yellow, tokensVal)
+ }
+ writeRow("tokens", tokensVal)
+ }
+
+ switch {
+ case commits == nil:
+ case len(commits) == 0:
+ writeRow("commits", "(none on this branch)")
+ case len(commits) == 1:
+ c := commits[0]
+ writeRow("commits", fmt.Sprintf("%s %s", c.ShortSHA, c.Message))
+ default:
+ writeRow("commits", fmt.Sprintf("(%d)", len(commits)))
+ for _, c := range commits {
+ fmt.Fprintf(&sb, " %s %s %s\n",
+ c.ShortSHA, c.Date.Format("2006-01-02"), c.Message)
+ }
+ }
+
+ return sb.String()
+}
+
+// buildFilesMarkdown renders touched files as a markdown block for verbose
+// and full output when an AI summary is present.
+func buildFilesMarkdown(files []string) string {
+ if len(files) == 0 {
+ return "\n## Files\n\n*(none)*\n"
+ }
+ var sb strings.Builder
+ sb.WriteString("\n## Files\n\n")
+ for _, f := range files {
+ fmt.Fprintf(&sb, "- `%s`\n", escapeInlineCodeText(f))
+ }
+ return sb.String()
+}
+
+// buildSummaryMarkdown renders a checkpoint AI summary into the brand
+// markdown shape used by entire's TTY renderer. The output is also the
+// source of truth for non-TTY callers, which write it verbatim.
+func buildSummaryMarkdown(s *checkpoint.Summary) string {
+ if s == nil {
+ return ""
+ }
+ var sb strings.Builder
+
+ fmt.Fprintf(&sb, "## Intent\n\n%s\n\n", escapeSummaryText(s.Intent))
+ fmt.Fprintf(&sb, "## Outcome\n\n%s\n\n", escapeSummaryText(s.Outcome))
+
+ if hasAnyLearning(s.Learnings) {
+ sb.WriteString("## Learnings\n\n")
+ if len(s.Learnings.Repo) > 0 {
+ sb.WriteString("### Repository\n\n")
+ for _, item := range s.Learnings.Repo {
+ fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
+ }
+ sb.WriteString("\n")
+ }
+ if len(s.Learnings.Code) > 0 {
+ sb.WriteString("### Code\n\n")
+ for _, item := range s.Learnings.Code {
+ fmt.Fprintf(&sb, "- %s\n", formatCodeLearning(item))
+ }
+ sb.WriteString("\n")
+ }
+ if len(s.Learnings.Workflow) > 0 {
+ sb.WriteString("### Workflow\n\n")
+ for _, item := range s.Learnings.Workflow {
+ fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
+ }
+ sb.WriteString("\n")
+ }
+ }
+
+ if len(s.Friction) > 0 {
+ sb.WriteString("## Friction\n\n")
+ for _, item := range s.Friction {
+ fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
+ }
+ sb.WriteString("\n")
+ }
+
+ if len(s.OpenItems) > 0 {
+ sb.WriteString("## Open Items\n\n")
+ for _, item := range s.OpenItems {
+ fmt.Fprintf(&sb, "- %s\n", escapeSummaryText(item))
+ }
+ sb.WriteString("\n")
+ }
+
+ return strings.TrimRight(sb.String(), "\n") + "\n"
+}
+
+func hasAnyLearning(l checkpoint.LearningsSummary) bool {
+ return len(l.Repo) > 0 || len(l.Code) > 0 || len(l.Workflow) > 0
+}
+
+func formatCodeLearning(c checkpoint.CodeLearning) string {
+ path := escapeSummaryText(c.Path)
+ finding := escapeSummaryText(c.Finding)
+ switch {
+ case c.Line > 0 && c.EndLine > 0:
+ return fmt.Sprintf("`%s:%d-%d` — %s", path, c.Line, c.EndLine, finding)
+ case c.Line > 0:
+ return fmt.Sprintf("`%s:%d` — %s", path, c.Line, finding)
+ default:
+ return fmt.Sprintf("`%s` — %s", path, finding)
+ }
+}
+
+func escapeSummaryText(s string) string {
+ return strings.ReplaceAll(strings.TrimSpace(s), "`", "‘")
+}
+
+func escapeInlineCodeText(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", " ")
+ s = strings.ReplaceAll(s, "\r", " ")
+ s = strings.ReplaceAll(s, "\n", " ")
+ return strings.ReplaceAll(s, "`", "‘")
+}
+
+// runExplainDefault shows all checkpoints on the current branch.
+// This is the default view when no flags are provided.
+func runExplainDefault(ctx context.Context, w io.Writer, noPager bool) error {
+ return runExplainBranchDefault(ctx, w, noPager)
+}
+
+// branchCheckpointsLimit is the max checkpoints to show in branch view
+const branchCheckpointsLimit = 100
+
+// commitScanLimit is how far back to scan git history for checkpoints
+const commitScanLimit = 500
+
+// errStopIteration is used to stop commit iteration early
+var errStopIteration = errors.New("stop iteration")
+
+// getCurrentWorktreeHash returns the hashed worktree ID for the current working directory.
+// This is used to filter shadow branches to only those belonging to this worktree.
+func getCurrentWorktreeHash(ctx context.Context) string {
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return ""
+ }
+ worktreeID, err := paths.GetWorktreeID(repoRoot)
+ if err != nil {
+ return ""
+ }
+ return checkpoint.HashWorktreeID(worktreeID)
+}
+
+// computeReachableFromMain returns a set of commit hashes on the main/default branch's first-parent chain.
+// On the default branch itself, returns an empty map (no filtering needed).
+// Only first-parent commits are included — commits from side branches merged into main are excluded,
+// since those could be feature branch commits that shouldn't be filtered out.
+func computeReachableFromMain(ctx context.Context, repo *git.Repository) map[plumbing.Hash]bool {
+ reachableFromMain := make(map[plumbing.Hash]bool)
+
+ isOnDefault, _ := strategy.IsOnDefaultBranch(repo)
+ if isOnDefault {
+ return reachableFromMain // No filtering needed on default branch
+ }
+
+ // Resolve main branch hash
+ var mainBranchHash plumbing.Hash
+ if defaultBranchName := strategy.GetDefaultBranchName(repo); defaultBranchName != "" {
+ ref, refErr := repo.Reference(plumbing.ReferenceName("refs/heads/"+defaultBranchName), true)
+ if refErr != nil {
+ ref, refErr = repo.Reference(plumbing.ReferenceName("refs/remotes/origin/"+defaultBranchName), true)
+ }
+ if refErr == nil {
+ mainBranchHash = ref.Hash()
+ }
+ }
+ if mainBranchHash == plumbing.ZeroHash {
+ mainBranchHash = strategy.GetMainBranchHash(repo)
+ }
+ if mainBranchHash == plumbing.ZeroHash {
+ return reachableFromMain
+ }
+
+ // Walk main's first-parent chain to build the set
+ _ = walkFirstParentCommits(ctx, repo, mainBranchHash, strategy.MaxCommitTraversalDepth, func(c *object.Commit) error { //nolint:errcheck // Best-effort
+ reachableFromMain[c.Hash] = true
+ return nil
+ })
+
+ return reachableFromMain
+}
+
+// walkFirstParentCommits walks the first-parent chain starting from `from`,
+// calling fn for each commit. It stops after visiting `limit` commits (0 = no limit).
+// This avoids the full DAG traversal that repo.Log() does, which follows ALL parents
+// of merge commits and can walk into unrelated branch history (e.g., main's full
+// history after merging main into a feature branch).
+func walkFirstParentCommits(ctx context.Context, repo *git.Repository, from plumbing.Hash, limit int, fn func(*object.Commit) error) error {
+ current, err := repo.CommitObject(from)
+ if err != nil {
+ return fmt.Errorf("failed to get commit %s: %w", from, err)
+ }
+
+ for count := 0; limit <= 0 || count < limit; count++ {
+ if err := ctx.Err(); err != nil {
+ return err //nolint:wrapcheck // Propagating context cancellation
+ }
+ if err := fn(current); err != nil {
+ if errors.Is(err, errStopIteration) {
+ return nil
+ }
+ return err
+ }
+
+ // Follow first parent only (skip merge parents).
+ // When there are no parents or parent lookup fails, we've reached the
+ // end of the chain — this is a normal termination, not an error.
+ if current.NumParents() == 0 {
+ return nil
+ }
+ parentHash := current.Hash
+ current, err = current.Parent(0)
+ if err != nil {
+ return fmt.Errorf("failed to load first parent of commit %s: %w", parentHash, err)
+ }
+ }
+ return nil
+}
+
+// getBranchCheckpoints returns checkpoints relevant to the current branch.
+// This is strategy-agnostic - it queries checkpoints directly from the checkpoint store.
+//
+// Behavior:
+// - On feature branches: only show checkpoints unique to this branch (not in main)
+// - On default branch (main/master): show all checkpoints in history (up to limit)
+// - Includes both committed checkpoints (trace/checkpoints/v1) and temporary checkpoints (shadow branches)
+func getBranchCheckpoints(ctx context.Context, repo *git.Repository, limit int) ([]strategy.RewindPoint, error) {
+ // Warn (once per process) if metadata branches are disconnected
+ strategy.WarnIfMetadataDisconnected()
+
+ v1Store := checkpoint.NewGitStore(repo)
+ v2URL, err := remote.FetchURL(ctx)
+ if err != nil {
+ logging.Debug(
+ ctx, "explain: using origin for branch checkpoint v2 store fetch remote",
+ slog.String("error", err.Error()),
+ )
+ v2URL = ""
+ }
+ v2Store := checkpoint.NewV2GitStore(repo, v2URL)
+ preferCheckpointsV2 := settings.IsCheckpointsV2Enabled(ctx)
+
+ // Get all committed checkpoints for lookup (v2-aware with v1 fallback).
+ committedInfos, err := listCommittedForExplain(ctx, v1Store, v2Store, preferCheckpointsV2)
+ if err != nil {
+ committedInfos = nil // Continue without committed checkpoints
+ }
+
+ // Build map of checkpoint ID -> committed info
+ committedByID := make(map[id.CheckpointID]checkpoint.CommittedInfo)
+ for _, info := range committedInfos {
+ if !info.CheckpointID.IsEmpty() {
+ committedByID[info.CheckpointID] = info
+ }
+ }
+
+ head, err := repo.Head()
+ if err != nil {
+ // Unborn HEAD (no commits yet) - return empty list instead of erroring
+ if errors.Is(err, plumbing.ErrReferenceNotFound) {
+ return []strategy.RewindPoint{}, nil
+ }
+ return nil, fmt.Errorf("failed to get HEAD: %w", err)
+ }
+
+ // Check if we're on the default branch (needed for getReachableTemporaryCheckpoints)
+ isOnDefault, _ := strategy.IsOnDefaultBranch(repo)
+
+ // Fetch metadata trees for reading session prompts (cheap tree lookups).
+ // Try v2 /main first, fall back to v1 metadata branch.
+ v1MetadataTree, _ := strategy.GetMetadataBranchTree(repo) //nolint:errcheck // Best-effort
+ v2MetadataTree, _ := strategy.GetV2MetadataBranchTree(repo) //nolint:errcheck // Best-effort
+ promptTree := resolvePromptTree(v1MetadataTree, v2MetadataTree, preferCheckpointsV2)
+
+ var points []strategy.RewindPoint
+
+ collectCheckpoint := func(c *object.Commit) {
+ cpID, found := trailers.ParseCheckpoint(c.Message)
+ if !found {
+ return
+ }
+ cpInfo, found := committedByID[cpID]
+ if !found {
+ return
+ }
+
+ message := strings.Split(c.Message, "\n")[0]
+ point := strategy.RewindPoint{
+ ID: c.Hash.String(),
+ Message: message,
+ Date: c.Committer.When,
+ IsLogsOnly: true, // Committed checkpoints are logs-only
+ CheckpointID: cpID,
+ SessionID: cpInfo.SessionID,
+ IsTaskCheckpoint: cpInfo.IsTask,
+ ToolUseID: cpInfo.ToolUseID,
+ Agent: cpInfo.Agent,
+ }
+ // Read session prompt from metadata tree (best-effort).
+ // Read prompt.txt directly from the latest session subdirectory instead of
+ // parsing the full transcript — prompt.txt is tiny vs multi-MB transcripts.
+ if promptTree != nil {
+ point.SessionPrompt = strategy.ReadLatestSessionPromptFromCommittedTree(promptTree, cpID, cpInfo.SessionCount)
+ }
+
+ points = append(points, point)
+ }
+
+ if isOnDefault {
+ // On the default branch, use full DAG walk to find checkpoint commits
+ // on merged feature branches (second parents of merge commits).
+ iter, iterErr := repo.Log(&git.LogOptions{
+ From: head.Hash(),
+ Order: git.LogOrderCommitterTime,
+ })
+ if iterErr != nil {
+ return nil, fmt.Errorf("failed to get commit log: %w", iterErr)
+ }
+ defer iter.Close()
+
+ count := 0
+ err = iter.ForEach(func(c *object.Commit) error {
+ if err := ctx.Err(); err != nil {
+ return err //nolint:wrapcheck // Propagating context cancellation
+ }
+ if count >= commitScanLimit {
+ return storer.ErrStop
+ }
+ count++
+ collectCheckpoint(c)
+ return nil
+ })
+ } else {
+ // On feature branches, use first-parent walk with branch filtering.
+ // This avoids walking into main's full history through merge commit parents.
+ reachableFromMain := computeReachableFromMain(ctx, repo)
+
+ err = walkFirstParentCommits(ctx, repo, head.Hash(), commitScanLimit, func(c *object.Commit) error {
+ // Once we hit a commit reachable from main on the first-parent chain,
+ // all earlier ancestors are also shared-with-main, so stop scanning.
+ if reachableFromMain[c.Hash] {
+ return errStopIteration
+ }
+ collectCheckpoint(c)
+ return nil
+ })
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("error iterating commits: %w", err)
+ }
+
+ // Get temporary checkpoints from ALL shadow branches whose base commit is reachable from HEAD.
+ tempPoints := getReachableTemporaryCheckpoints(ctx, repo, v1Store, head.Hash(), isOnDefault, limit)
+ points = append(points, tempPoints...)
+
+ // Sort by date, most recent first
+ sort.Slice(points, func(i, j int) bool {
+ return points[i].Date.After(points[j].Date)
+ })
+
+ // Apply limit
+ if len(points) > limit {
+ points = points[:limit]
+ }
+
+ return points, nil
+}
+
+// getReachableTemporaryCheckpoints returns temporary checkpoints from shadow branches
+// whose base commit is reachable from the given HEAD hash and that belong to this worktree.
+// For default branches, all shadow branches for this worktree are included.
+// For feature branches, only shadow branches whose base commit is in HEAD's history are included.
+func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store *checkpoint.GitStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint {
+ var points []strategy.RewindPoint
+
+ // Compute current worktree's hash for filtering shadow branches
+ currentWorktreeHash := getCurrentWorktreeHash(ctx)
+
+ shadowBranches, _ := store.ListTemporary(ctx) //nolint:errcheck // Best-effort
+ for _, sb := range shadowBranches {
+ // Filter by worktree: only show shadow branches belonging to this worktree.
+ // Skip filtering if currentWorktreeHash is empty (error computing it) to avoid
+ // accidentally filtering out ALL shadow branches.
+ _, branchWorktreeHash, parsed := checkpoint.ParseShadowBranchName(sb.BranchName)
+ if currentWorktreeHash != "" && parsed && branchWorktreeHash != "" && branchWorktreeHash != currentWorktreeHash {
+ continue
+ }
+
+ // Check if this shadow branch's base commit is reachable from current HEAD
+ if !isShadowBranchReachable(ctx, repo, sb.BaseCommit, headHash, isOnDefault) {
+ continue
+ }
+
+ // List checkpoints from this shadow branch
+ tempCheckpoints, _ := store.ListCheckpointsForBranch(ctx, sb.BranchName, "", limit) //nolint:errcheck // Best-effort
+ for _, tc := range tempCheckpoints {
+ point := convertTemporaryCheckpoint(repo, tc)
+ if point != nil {
+ points = append(points, *point)
+ }
+ }
+ }
+
+ return points
+}
+
+// isShadowBranchReachable checks if a shadow branch's base commit is reachable from HEAD.
+// For default branches, all shadow branches are considered reachable.
+// For feature branches, we check if any commit with the base commit prefix is in HEAD's history.
+func isShadowBranchReachable(ctx context.Context, repo *git.Repository, baseCommit string, headHash plumbing.Hash, isOnDefault bool) bool {
+ // For default branch: all shadow branches are potentially relevant
+ if isOnDefault {
+ return true
+ }
+
+ // Check if base commit hash prefix matches any commit in HEAD's first-parent chain
+ found := false
+ _ = walkFirstParentCommits(ctx, repo, headHash, commitScanLimit, func(c *object.Commit) error { //nolint:errcheck // Best-effort
+ if strings.HasPrefix(c.Hash.String(), baseCommit) {
+ found = true
+ return errStopIteration
+ }
+ return nil
+ })
+
+ return found
+}
+
+// convertTemporaryCheckpoint converts a TemporaryCheckpointInfo to a RewindPoint.
+// Returns nil if the checkpoint should be skipped (no tree changes or can't be read).
+//
+// Filtering uses hasAnyChanges (O(1) tree hash comparison) rather than hasCodeChanges
+// (O(files) full diff). This means metadata-only checkpoints (.trace/ changes without
+// code changes) are kept — only true no-ops (identical tree as parent) are dropped.
+// This trade-off is intentional for list-view performance.
+func convertTemporaryCheckpoint(repo *git.Repository, tc checkpoint.TemporaryCheckpointInfo) *strategy.RewindPoint {
+ shadowCommit, commitErr := repo.CommitObject(tc.CommitHash)
+ if commitErr != nil {
+ return nil
+ }
+
+ // Skip no-op commits where the tree is identical to the parent's.
+ // Note: this keeps metadata-only changes (e.g. transcript updates in .trace/)
+ // since those produce a different tree hash. See hasAnyChanges godoc.
+ if !hasAnyChanges(shadowCommit) {
+ return nil
+ }
+
+ // Read session prompt from the shadow branch commit's tree (not from trace/checkpoints/v1)
+ // Temporary checkpoints store their metadata in the shadow branch, not in trace/checkpoints/v1
+ var sessionPrompt string
+ shadowTree, treeErr := shadowCommit.Tree()
+ if treeErr == nil {
+ sessionPrompt = strategy.ReadSessionPromptFromTree(shadowTree, tc.MetadataDir)
+ }
+
+ return &strategy.RewindPoint{
+ ID: tc.CommitHash.String(),
+ Message: tc.Message,
+ MetadataDir: tc.MetadataDir,
+ Date: tc.Timestamp,
+ IsTaskCheckpoint: tc.IsTaskCheckpoint,
+ ToolUseID: tc.ToolUseID,
+ SessionID: tc.SessionID,
+ SessionPrompt: sessionPrompt,
+ IsLogsOnly: false, // Temporary checkpoints can be fully rewound
+ }
+}
+
+// runExplainBranchWithFilter shows checkpoints on the current branch, optionally filtered by session.
+// This is strategy-agnostic - it queries checkpoints directly.
+func runExplainBranchWithFilter(ctx context.Context, w io.Writer, noPager bool, sessionFilter string) error {
+ repo, err := openRepository(ctx)
+ if err != nil {
+ return fmt.Errorf("not a git repository: %w", err)
+ }
+
+ // Get current branch name
+ branchName := strategy.GetCurrentBranchName(repo)
+ if branchName == "" {
+ // Detached HEAD state or unborn HEAD - try to use short commit hash if possible
+ head, headErr := repo.Head()
+ if headErr != nil {
+ // Unborn HEAD (no commits yet) - treat as empty history instead of erroring
+ if errors.Is(headErr, plumbing.ErrReferenceNotFound) {
+ branchName = "HEAD (no commits yet)"
+ } else {
+ return fmt.Errorf("failed to get HEAD: %w", headErr)
+ }
+ } else {
+ branchName = "HEAD (" + head.Hash().String()[:7] + ")"
+ }
+ }
+
+ // Get checkpoints for this branch (strategy-agnostic)
+ points, err := getBranchCheckpoints(ctx, repo, branchCheckpointsLimit)
+ if err != nil {
+ // If context was cancelled (e.g. user hit Ctrl+C), exit silently
+ if ctx.Err() != nil {
+ return NewSilentError(ctx.Err())
+ }
+ // Log the error but continue with empty list so user sees helpful message
+ logging.Warn(ctx, "failed to get branch checkpoints", "error", err)
+ points = nil
+ }
+
+ // Format output
+ output := formatBranchCheckpoints(w, branchName, points, sessionFilter)
+
+ outputExplainContent(w, output, noPager)
+ return nil
+}
diff --git a/cli/explain_3_test.go b/cli/explain_3_test.go
new file mode 100644
index 0000000..d47892b
--- /dev/null
+++ b/cli/explain_3_test.go
@@ -0,0 +1,797 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFormatSessionInfo_ShowsMessageAndFilesWhenNoInteractions(t *testing.T) {
+ // Test that checkpoints without transcript but with files show both message and files
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-12-incremental-with-files",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "def5678",
+ Timestamp: now,
+ IsTaskCheckpoint: true,
+ Message: "Running tests for API endpoint (toolu_02DEF)",
+ Interactions: []interaction{}, // Empty - no transcript
+ Files: []string{"api/endpoint.go", "api/endpoint_test.go"},
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Should show the commit message
+ if !strings.Contains(output, "Running tests for API endpoint (toolu_02DEF)") {
+ t.Errorf("expected output to contain commit message, got:\n%s", output)
+ }
+
+ // Should also show the files
+ if !strings.Contains(output, "Files Modified") {
+ t.Errorf("expected output to contain 'Files Modified', got:\n%s", output)
+ }
+ if !strings.Contains(output, "api/endpoint.go") {
+ t.Errorf("expected output to contain modified file, got:\n%s", output)
+ }
+}
+
+func TestFormatSessionInfo_DoesNotShowMessageWhenHasInteractions(t *testing.T) {
+ // Test that checkpoints WITH interactions don't show the message separately
+ // (the interactions already contain the content)
+ now := time.Now()
+ session := &strategy.Session{
+ ID: "2025-12-12-full-checkpoint",
+ Strategy: "manual-commit",
+ StartTime: now,
+ Checkpoints: []strategy.Checkpoint{},
+ }
+
+ checkpointDetails := []checkpointDetail{
+ {
+ Index: 1,
+ ShortID: "ghi9012",
+ Timestamp: now,
+ IsTaskCheckpoint: true,
+ Message: "Completed 'dev' agent: Implement feature (toolu_03GHI)",
+ Interactions: []interaction{
+ {
+ Prompt: "Implement the feature",
+ Responses: []string{"I've implemented the feature by..."},
+ Files: []string{"feature.go"},
+ },
+ },
+ },
+ }
+
+ output := formatSessionInfo(session, "", checkpointDetails)
+
+ // Should show the interaction content
+ if !strings.Contains(output, "Implement the feature") {
+ t.Errorf("expected output to contain prompt, got:\n%s", output)
+ }
+ if !strings.Contains(output, "I've implemented the feature by") {
+ t.Errorf("expected output to contain response, got:\n%s", output)
+ }
+
+ // The message should NOT appear as a separate line (it's redundant when we have interactions)
+ // The output should contain ## Prompt and ## Responses for the interaction
+ if !strings.Contains(output, "## Prompt") {
+ t.Errorf("expected output to contain '## Prompt' when has interactions, got:\n%s", output)
+ }
+}
+
+func TestExplainCmd_HasCheckpointFlag(t *testing.T) {
+ cmd := newExplainCmd()
+
+ flag := cmd.Flags().Lookup("checkpoint")
+ if flag == nil {
+ t.Error("expected --checkpoint flag to exist")
+ }
+}
+
+func TestExplainCmd_HasShortFlag(t *testing.T) {
+ cmd := newExplainCmd()
+
+ flag := cmd.Flags().Lookup("short")
+ if flag == nil {
+ t.Fatal("expected --short flag to exist")
+ return // unreachable but satisfies staticcheck
+ }
+
+ // Should have -s shorthand
+ if flag.Shorthand != "s" {
+ t.Errorf("expected -s shorthand, got %q", flag.Shorthand)
+ }
+}
+
+func TestExplainCmd_HasFullFlag(t *testing.T) {
+ cmd := newExplainCmd()
+
+ flag := cmd.Flags().Lookup("full")
+ if flag == nil {
+ t.Error("expected --full flag to exist")
+ }
+}
+
+func TestExplainCmd_HasRawTranscriptFlag(t *testing.T) {
+ cmd := newExplainCmd()
+
+ flag := cmd.Flags().Lookup("raw-transcript")
+ if flag == nil {
+ t.Error("expected --raw-transcript flag to exist")
+ }
+}
+
+func TestRunExplain_MutualExclusivityError(t *testing.T) {
+ var buf, errBuf bytes.Buffer
+
+ // Providing both --session and --checkpoint should error
+ err := runExplain(context.Background(), &buf, &errBuf, "session-id", "", "checkpoint-id", "", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error when multiple flags provided")
+ }
+ if !strings.Contains(err.Error(), "cannot specify multiple") {
+ t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
+ }
+}
+
+func TestRunExplainCheckpoint_NotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo with an initial commit (required for checkpoint lookup)
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "nonexistent123", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error for nonexistent checkpoint")
+ }
+ if !strings.Contains(err.Error(), "checkpoint not found") {
+ t.Errorf("expected 'checkpoint not found' error, got: %v", err)
+ }
+}
+
+func TestRunExplainCheckpoint_V2OnlyCheckpoint(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ if err != nil {
+ t.Fatalf("failed to open git repo: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := wt.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
+ t.Fatalf("failed to create .trace directory: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("777777777777")
+ ctx := context.Background()
+
+ if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-v2",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello from v2"}]}}` + "\n")),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }); err != nil {
+ t.Fatalf("failed to write v2 checkpoint: %v", err)
+ }
+
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "777777", false, false, false, false, false, false, false)
+ if err != nil {
+ t.Fatalf("expected success for v2-only checkpoint, got error: %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "● Checkpoint 777777777777") {
+ t.Fatalf("expected checkpoint header in output, got: %s", output)
+ }
+ if !strings.Contains(output, "session-v2") {
+ t.Fatalf("expected v2 session ID in output, got: %s", output)
+ }
+}
+
+func TestRunExplainCheckpoint_V2OnlyRawTranscript(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ if err != nil {
+ t.Fatalf("failed to open git repo: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := wt.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
+ t.Fatalf("failed to create .trace directory: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("888888888888")
+ ctx := context.Background()
+
+ if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-v2",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw from v2"}]}}` + "\n")),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }); err != nil {
+ t.Fatalf("failed to write v2 checkpoint: %v", err)
+ }
+
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "888888", false, false, false, true, false, false, false)
+ if err != nil {
+ t.Fatalf("expected success for v2-only raw transcript, got error: %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "raw from v2") {
+ t.Fatalf("expected v2 raw transcript in output, got: %s", output)
+ }
+}
+
+func TestRunExplainCheckpoint_V2CheckpointRemoteFallbackResolvesRawTranscript(t *testing.T) {
+ ctx := context.Background()
+
+ emptyConfig := filepath.Join(t.TempDir(), "empty-git-config")
+ require.NoError(t, os.WriteFile(emptyConfig, []byte(""), 0o644))
+ t.Setenv("GIT_CONFIG_GLOBAL", emptyConfig)
+ t.Setenv("GIT_CONFIG_SYSTEM", emptyConfig)
+
+ checkpointDir := t.TempDir()
+ testutil.InitRepo(t, checkpointDir)
+ testutil.WriteFile(t, checkpointDir, "checkpoint.txt", "checkpoint")
+ testutil.GitAdd(t, checkpointDir, "checkpoint.txt")
+ testutil.GitCommit(t, checkpointDir, "checkpoint init")
+
+ checkpointRepo, err := git.PlainOpen(checkpointDir)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ // Close the underlying storage to release file descriptors before
+ // t.TempDir() attempts to remove the directory.
+ if storer, ok := checkpointRepo.Storer.(interface{ Close() error }); ok {
+ _ = storer.Close()
+ }
+ })
+
+ cpID := id.MustCheckpointID("121212121212")
+ rawTranscript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw from checkpoint_remote"}]}}` + "\n")
+ checkpointStore := checkpoint.NewV2GitStore(checkpointRepo, "origin")
+ require.NoError(t, checkpointStore.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-checkpoint-remote",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(rawTranscript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ localDir := t.TempDir()
+ t.Chdir(localDir)
+
+ testutil.InitRepo(t, localDir)
+ testutil.WriteFile(t, localDir, "local.txt", "local")
+ testutil.GitAdd(t, localDir, "local.txt")
+ testutil.GitCommit(t, localDir, "local init")
+
+ cmd := exec.CommandContext(ctx, "git", "remote", "add", "origin", "git@github.com:user/source.git")
+ cmd.Dir = localDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ require.NoError(t, cmd.Run())
+
+ sshScript := filepath.Join(t.TempDir(), "fake-ssh")
+ require.NoError(t, os.WriteFile(sshScript, []byte(`#!/bin/bash
+set -euo pipefail
+cmd="${@: -1}"
+case "$cmd" in
+ *"user/source.git"*)
+ echo "origin intentionally unavailable" >&2
+ exit 1
+ ;;
+ *"org/checkpoints.git"*) repo="$CHECKPOINT_REPO" ;;
+ *)
+ echo "unexpected ssh command: $cmd" >&2
+ exit 1
+ ;;
+esac
+exec git-upload-pack "$repo"
+`), 0o755))
+ t.Setenv("GIT_SSH", sshScript)
+ t.Setenv("GIT_SSH_COMMAND", sshScript) // GIT_SSH_COMMAND takes priority over GIT_SSH on systems where it's set globally.
+ t.Setenv("CHECKPOINT_REPO", checkpointDir)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(localDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(localDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true, "checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`),
+ 0o644,
+ ))
+
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(ctx, &buf, &errBuf, "121212", false, false, false, true, false, false, false)
+ require.NoError(t, err)
+ require.Contains(t, buf.String(), "raw from checkpoint_remote")
+}
+
+func TestRunExplainCheckpoint_V2UsesCompactTranscriptForIntent(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ if err != nil {
+ t.Fatalf("failed to open git repo: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := wt.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
+ t.Fatalf("failed to create .trace directory: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
+ t.Fatalf("failed to write settings: %v", err)
+ }
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("999999999999")
+ ctx := context.Background()
+
+ compactTranscript := []byte(
+ `{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"user","ts":"2026-01-01T00:00:00Z","content":[{"text":"compact prompt text"}]}` + "\n" +
+ `{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"assistant","ts":"2026-01-01T00:00:01Z","id":"m1","content":[{"type":"text","text":"assistant reply"}]}` + "\n",
+ )
+
+ if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-v2",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw prompt text"}]}}` + "\n")),
+ CompactTranscript: compactTranscript,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ CheckpointTranscriptStart: 0,
+ }); err != nil {
+ t.Fatalf("failed to write v2 checkpoint: %v", err)
+ }
+
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "999999", false, false, false, false, false, false, false)
+ if err != nil {
+ t.Fatalf("expected success for v2 checkpoint, got error: %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "## Intent") {
+ t.Fatalf("expected '## Intent' heading in no-color output, got: %s", output)
+ }
+ if !strings.Contains(output, "compact prompt text") {
+ t.Fatalf("expected compact transcript to drive intent extraction, got: %s", output)
+ }
+}
+
+func TestRunExplainCheckpoint_V2PreferredGenerateWritesBothStores(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ v1Store := checkpoint.NewGitStore(repo)
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("aabbccddeeff")
+ ctx := context.Background()
+
+ transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"generate test"}]}}` + "\n" +
+ `{"type":"assistant","message":{"content":"done"}}` + "\n")
+
+ // Dual-write: checkpoint exists in both v1 and v2.
+ require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // generate=true, force=true — should succeed by writing to both v1 and v2 stores.
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(ctx, &buf, &errBuf, "aabbcc", false, false, false, false, true, true, false)
+ // Generation requires an AI summarizer which isn't available in unit tests,
+ // but the important thing is we don't get the old "only v1 checkpoints supported" error.
+ if err != nil && strings.Contains(err.Error(), "summary updates are currently supported only for v1 checkpoints") {
+ t.Fatalf("should not reject v2-resolved checkpoints for generation when v1 has the data: %v", err)
+ }
+}
+
+func TestRunExplainCheckpoint_V2OnlyGenerateSucceedsViaV2Store(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("f1f2f3f4f5f6")
+ ctx := context.Background()
+
+ transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"v2-only generate"}]}}` + "\n" +
+ `{"type":"assistant","message":{"content":"done"}}` + "\n")
+
+ // Write to v2 only — no v1 checkpoint exists.
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-v2-only",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // generate=true, force=true — should not fail with "failed to save summary"
+ // because v2 store can persist even when v1 doesn't have the checkpoint.
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(ctx, &buf, &errBuf, "f1f2f3", false, false, false, false, true, true, false)
+ if err != nil {
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "claude") || strings.Contains(errMsg, "executable file not found") {
+ t.Skipf("skipping: summarizer unavailable in CI: %v", err)
+ }
+ require.NotContains(t, errMsg, "failed to save summary",
+ "v2-only checkpoint should persist summary via v2 store")
+ }
+}
+
+func TestRunExplainCheckpoint_V2FallsBackToFullWhenCompactMissing(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("e1e2e3e4e5e6")
+ ctx := context.Background()
+
+ rawTranscript := []byte(
+ `{"type":"user","message":{"content":[{"type":"text","text":"raw fallback prompt"}]}}` + "\n" +
+ `{"type":"assistant","message":{"content":"raw reply"}}` + "\n",
+ )
+
+ // Write checkpoint with raw transcript but NO compact transcript.
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-no-compact",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(rawTranscript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // Default explain (not --full) should fall back to /full/current transcript
+ // when compact transcript is missing on /main.
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(ctx, &buf, &errBuf, "e1e2e3", false, false, false, false, false, false, false)
+ require.NoError(t, err)
+
+ output := buf.String()
+ require.Contains(t, output, "raw fallback prompt",
+ "should use raw transcript from /full/current when compact is missing")
+}
+
+func TestRunExplainCheckpoint_V2CompactTranscriptNotUsedForGenerate(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ v1Store := checkpoint.NewGitStore(repo)
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ cpID := id.MustCheckpointID("c0c1c2c3c4c5")
+ ctx := context.Background()
+
+ rawTranscript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw prompt for summarizer"}]}}` + "\n" +
+ `{"type":"assistant","message":{"content":"raw reply"}}` + "\n")
+ compactTranscript := []byte(`{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"user","content":[{"text":"compact prompt"}]}` + "\n")
+
+ // Dual-write with compact transcript.
+ require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-compact",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(rawTranscript),
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-compact",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(rawTranscript),
+ CompactTranscript: compactTranscript,
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // generate=true — should NOT fail with "no transcript content" which would
+ // indicate the compact transcript was incorrectly fed to the summarizer.
+ var buf, errBuf bytes.Buffer
+ err = runExplainCheckpoint(ctx, &buf, &errBuf, "c0c1c2", false, false, false, false, true, true, false)
+ if err != nil && strings.Contains(err.Error(), "no transcript content for this checkpoint") {
+ t.Fatalf("compact transcript should not be used for --generate; raw transcript should be used instead: %v", err)
+ }
+}
+
+func TestListCommittedForExplain_MergesV1AndV2(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "f.txt"), []byte("x"), 0o644))
+ _, err = wt.Add("f.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("init", &git.CommitOptions{
+ Author: &object.Signature{Name: "T", Email: "t@t.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ v1Store := checkpoint.NewGitStore(repo)
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")
+
+ // Write a v1-only checkpoint (pre-v2 era).
+ v1OnlyID := id.MustCheckpointID("aaa111222333")
+ require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: v1OnlyID,
+ SessionID: "session-v1-only",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "T",
+ AuthorEmail: "t@t.com",
+ }))
+
+ // Write a dual-write checkpoint (exists in both v1 and v2).
+ dualID := id.MustCheckpointID("bbb444555666")
+ require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: dualID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "T",
+ AuthorEmail: "t@t.com",
+ }))
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: dualID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "T",
+ AuthorEmail: "t@t.com",
+ }))
+
+ // With v2 preferred: should return both the dual-write AND the v1-only checkpoint.
+ results, err := listCommittedForExplain(ctx, v1Store, v2Store, true)
+ require.NoError(t, err)
+
+ foundIDs := make(map[id.CheckpointID]bool)
+ for _, r := range results {
+ foundIDs[r.CheckpointID] = true
+ }
+ require.True(t, foundIDs[v1OnlyID], "v1-only checkpoint should be visible when v2 is preferred")
+ require.True(t, foundIDs[dualID], "dual-write checkpoint should be visible")
+
+ // No duplicates: dual checkpoint should appear exactly once.
+ dualCount := 0
+ for _, r := range results {
+ if r.CheckpointID == dualID {
+ dualCount++
+ }
+ }
+ require.Equal(t, 1, dualCount, "dual-write checkpoint should not be duplicated")
+}
diff --git a/cli/explain_4.go b/cli/explain_4.go
new file mode 100644
index 0000000..2ef862c
--- /dev/null
+++ b/cli/explain_4.go
@@ -0,0 +1,562 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "golang.org/x/term"
+)
+
+// runExplainBranchDefault shows all checkpoints on the current branch grouped by date.
+// This is a convenience wrapper that calls runExplainBranchWithFilter with no filter.
+func runExplainBranchDefault(ctx context.Context, w io.Writer, noPager bool) error {
+ return runExplainBranchWithFilter(ctx, w, noPager, "")
+}
+
+// outputExplainContent outputs content with optional pager support.
+func outputExplainContent(w io.Writer, content string, noPager bool) {
+ if noPager {
+ fmt.Fprint(w, content)
+ } else {
+ outputWithPager(w, content)
+ }
+}
+
+// runExplainCommit looks up the checkpoint associated with a commit.
+// Extracts the Trace-Checkpoint trailer and delegates to checkpoint detail view.
+// If no trailer found, shows a message indicating no associated checkpoint.
+func runExplainCommit(ctx context.Context, w, errW io.Writer, commitRef string, noPager, verbose, full, rawTranscript, generate, force, searchAll bool) error {
+ repo, err := openRepository(ctx)
+ if err != nil {
+ return fmt.Errorf("not a git repository: %w", err)
+ }
+
+ // Resolve the commit reference, erroring on hex-prefix ambiguity
+ // instead of silently picking the first matching commit.
+ hash, ambiguousMatches, err := resolveCommitUnambiguous(repo, commitRef)
+ if err != nil {
+ if errors.Is(err, errAmbiguousCommitPrefix) {
+ renderAmbiguousPrefixFailure(errW, commitRef, "commits", buildAmbiguousCommitMatches(repo, ambiguousMatches))
+ return NewSilentError(err)
+ }
+ return renderExplainFailure(errW, "Commit not found", []explainRow{
+ {Label: "ref", Value: commitRef},
+ }, fmt.Errorf("commit not found: %s", commitRef))
+ }
+
+ commit, err := repo.CommitObject(hash)
+ if err != nil {
+ return fmt.Errorf("failed to get commit: %w", err)
+ }
+
+ // Extract Trace-Checkpoint trailer
+ checkpointID, hasCheckpoint := trailers.ParseCheckpoint(commit.Message)
+ if !hasCheckpoint {
+ // Side-effect modes must error so scripts can distinguish "done"
+ // from "didn't happen"; read-only modes print a friendly message.
+ if generate || rawTranscript {
+ return fmt.Errorf("cannot %s: commit %s has no Trace-Checkpoint trailer", generateOrRawLabel(generate), abbreviateCommitHash(repo, hash))
+ }
+ printNoTrailerMessage(w, repo, hash)
+ return nil
+ }
+
+ // Delegate to checkpoint detail view, forwarding the full flag set so
+ // --generate / --raw-transcript / --force work via --commit as well.
+ return runExplainCheckpoint(ctx, w, errW, checkpointID.String(), noPager, verbose, full, rawTranscript, generate, force, searchAll)
+}
+
+// formatSessionInfo formats session information for display.
+//
+// NOTE: This function has no production caller — `trace explain --session`
+// flows through formatBranchCheckpoints (the list view filtered by session),
+// not through here. It is kept for tests that exercise the per-checkpoint
+// markdown body shape used elsewhere; restyling it for the brand format was
+// not worth the diff. If the CLI ever grows a session-detail surface, revisit.
+func formatSessionInfo(session *strategy.Session, sourceRef string, checkpoints []checkpointDetail) string {
+ var sb strings.Builder
+
+ // Session header
+ fmt.Fprintf(&sb, "Session: %s\n", session.ID)
+ fmt.Fprintf(&sb, "Strategy: %s\n", session.Strategy)
+
+ if !session.StartTime.IsZero() {
+ fmt.Fprintf(&sb, "Started: %s\n", session.StartTime.Format("2006-01-02 15:04:05"))
+ }
+
+ if sourceRef != "" {
+ fmt.Fprintf(&sb, "Source Ref: %s\n", sourceRef)
+ }
+
+ fmt.Fprintf(&sb, "Checkpoints: %d\n", len(checkpoints))
+
+ // Checkpoint details
+ for _, cp := range checkpoints {
+ sb.WriteString("\n")
+
+ // Checkpoint header
+ taskMarker := ""
+ if cp.IsTaskCheckpoint {
+ taskMarker = " [Task]"
+ }
+ fmt.Fprintf(&sb, "─── Checkpoint %d [%s] %s%s ───\n",
+ cp.Index, cp.ShortID, cp.Timestamp.Format("2006-01-02 15:04"), taskMarker)
+ sb.WriteString("\n")
+
+ // Display all interactions in this checkpoint
+ for i, inter := range cp.Interactions {
+ // For multiple interactions, add a sub-header
+ if len(cp.Interactions) > 1 {
+ fmt.Fprintf(&sb, "### Interaction %d\n\n", i+1)
+ }
+
+ // Prompt section
+ if inter.Prompt != "" {
+ sb.WriteString("## Prompt\n\n")
+ sb.WriteString(inter.Prompt)
+ sb.WriteString("\n\n")
+ }
+
+ // Response section
+ if len(inter.Responses) > 0 {
+ sb.WriteString("## Responses\n\n")
+ sb.WriteString(strings.Join(inter.Responses, "\n\n"))
+ sb.WriteString("\n\n")
+ }
+
+ // Files modified for this interaction
+ if len(inter.Files) > 0 {
+ fmt.Fprintf(&sb, "Files Modified (%d):\n", len(inter.Files))
+ for _, file := range inter.Files {
+ fmt.Fprintf(&sb, " - %s\n", file)
+ }
+ sb.WriteString("\n")
+ }
+ }
+
+ // If no interactions, show message and/or files
+ if len(cp.Interactions) == 0 {
+ // Show commit message as summary when no transcript available
+ if cp.Message != "" {
+ sb.WriteString(cp.Message)
+ sb.WriteString("\n\n")
+ }
+ // Show aggregate files if available
+ if len(cp.Files) > 0 {
+ fmt.Fprintf(&sb, "Files Modified (%d):\n", len(cp.Files))
+ for _, file := range cp.Files {
+ fmt.Fprintf(&sb, " - %s\n", file)
+ }
+ }
+ }
+ }
+
+ return sb.String()
+}
+
+// pagerLookupEnv is overridable for tests so pager env-gate behavior can be
+// asserted without depending on the host's PAGER / LESS settings.
+var pagerLookupEnv = os.Getenv
+
+// buildPagerCmd constructs the pager subprocess and injects LESS=-R when the
+// default Unix pager is less and the user has not customized PAGER or LESS.
+func buildPagerCmd(ctx context.Context) (*exec.Cmd, string) {
+ pager := pagerLookupEnv(pagerEnvVar)
+ if pager == "" {
+ if runtime.GOOS == windowsGOOS {
+ pager = "more"
+ } else {
+ pager = lessPagerName
+ }
+ }
+
+ cmd := exec.CommandContext(ctx, pager)
+ if pager == lessPagerName && pagerLookupEnv(pagerEnvVar) == "" && pagerLookupEnv(lessEnvVar) == "" {
+ cmd.Env = upsertEnv(os.Environ(), lessEnvVar, "-R")
+ }
+ return cmd, pager
+}
+
+func upsertEnv(env []string, key, value string) []string {
+ prefix := key + "="
+ entry := prefix + value
+ result := make([]string, 0, len(env)+1)
+ replaced := false
+ for _, e := range env {
+ if strings.HasPrefix(e, prefix) {
+ if !replaced {
+ result = append(result, entry)
+ replaced = true
+ }
+ continue
+ }
+ result = append(result, e)
+ }
+ if !replaced {
+ result = append(result, entry)
+ }
+ return result
+}
+
+// removeEnvKey returns env with every entry for key dropped. Useful when a
+// outputWithPager outputs content through a pager if stdout is a terminal and content is long.
+func outputWithPager(w io.Writer, content string) {
+ // Check if we're writing to stdout and it's a terminal
+ if f, ok := w.(*os.File); ok && f == os.Stdout && interactive.IsTerminalWriter(w) {
+ // Get terminal height
+ _, height, err := term.GetSize(int(f.Fd())) //nolint:gosec // G115: same as above
+ if err != nil {
+ height = 24 // Default fallback
+ }
+
+ // Count lines in content
+ lineCount := strings.Count(content, "\n")
+
+ // Use pager if content exceeds terminal height
+ if lineCount > height-2 {
+ // Use context.Background() intentionally — pagers are interactive
+ // processes that handle signals (including SIGINT) themselves.
+ // Using the cancellable ctx would cause exec.CommandContext to
+ // SIGKILL the pager on Ctrl+C, preventing it from restoring
+ // terminal state (raw mode, echo, etc.).
+ cmd, _ := buildPagerCmd(context.Background())
+ cmd.Stdin = strings.NewReader(content)
+ cmd.Stdout = f
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ // Fallback to direct output if pager fails
+ fmt.Fprint(w, content)
+ }
+ return
+ }
+ }
+
+ // Direct output for non-terminal or short content
+ fmt.Fprint(w, content)
+}
+
+// Constants for formatting output
+const (
+ // maxIntentDisplayLength is the maximum length for intent text before truncation
+ maxIntentDisplayLength = 80
+ // maxMessageDisplayLength is the maximum length for checkpoint messages before truncation
+ maxMessageDisplayLength = 80
+ // maxPromptDisplayLength is the maximum length for session prompts before truncation
+ maxPromptDisplayLength = 60
+ // checkpointIDDisplayLength is the number of characters to show from checkpoint IDs
+ checkpointIDDisplayLength = 12
+)
+
+// formatBranchCheckpoints formats checkpoint information for a branch.
+// Groups commits by checkpoint ID and shows the prompt for each checkpoint.
+// If sessionFilter is non-empty, only shows checkpoints matching that session ID (or prefix).
+func formatBranchCheckpoints(w io.Writer, branchName string, points []strategy.RewindPoint, sessionFilter string) string {
+ var sb strings.Builder
+ styles := newStatusStyles(w)
+
+ // Filter by session if specified (must happen before counting)
+ if sessionFilter != "" {
+ var filtered []strategy.RewindPoint
+ for _, p := range points {
+ if p.SessionID == sessionFilter || strings.HasPrefix(p.SessionID, sessionFilter) {
+ filtered = append(filtered, p)
+ }
+ }
+ points = filtered
+ }
+
+ // Group by checkpoint ID so the count matches the rendered group count
+ groups := groupByCheckpointID(points)
+
+ branchRows := []explainRow{
+ {Label: "branch", Value: branchName},
+ }
+ if sessionFilter != "" {
+ branchRows = append(branchRows, explainRow{Label: "session", Value: sessionFilter})
+ }
+ branchRows = append(branchRows, explainRow{Label: "checkpoints", Value: strconv.Itoa(len(groups))})
+
+ sb.WriteString(styles.metadataRows(branchRows))
+ sb.WriteString("\n")
+
+ if len(groups) == 0 {
+ sb.WriteString("No checkpoints found on this branch.\n")
+ sb.WriteString("Checkpoints will appear here after you save changes during an agent session.\n")
+ return sb.String()
+ }
+
+ // Output each checkpoint group
+ for _, group := range groups {
+ formatCheckpointGroup(&sb, group, styles)
+ sb.WriteString("\n")
+ }
+
+ return sb.String()
+}
+
+// checkpointGroup represents a group of commits sharing the same checkpoint ID.
+type checkpointGroup struct {
+ checkpointID string
+ prompt string
+ isTemporary bool // true if any commit is not logs-only (can be rewound)
+ isTask bool // true if this is a task checkpoint
+ commits []commitEntry
+}
+
+// commitEntry represents a single git commit within a checkpoint.
+type commitEntry struct {
+ date time.Time
+ gitSHA string // short git SHA
+ message string
+}
+
+// groupByCheckpointID groups rewind points by their checkpoint ID.
+// Returns groups sorted by latest commit timestamp (most recent first).
+func groupByCheckpointID(points []strategy.RewindPoint) []checkpointGroup {
+ if len(points) == 0 {
+ return nil
+ }
+
+ // Build map of checkpoint ID -> group
+ groupMap := make(map[string]*checkpointGroup)
+ var order []string // Track insertion order for stable iteration
+
+ for _, point := range points {
+ // Determine the checkpoint ID to use for grouping
+ cpID := point.CheckpointID.String()
+ if cpID == "" {
+ // Temporary checkpoints: group by session ID to preserve per-session prompts
+ // Use session ID prefix for readability (format: YYYY-MM-DD-uuid)
+ cpID = point.SessionID
+ if cpID == "" {
+ cpID = "temporary" // Fallback if no session ID
+ }
+ }
+
+ group, exists := groupMap[cpID]
+ if !exists {
+ group = &checkpointGroup{
+ checkpointID: cpID,
+ prompt: point.SessionPrompt,
+ isTemporary: !point.IsLogsOnly,
+ isTask: point.IsTaskCheckpoint,
+ }
+ groupMap[cpID] = group
+ order = append(order, cpID)
+ }
+
+ // Short git SHA (7 chars)
+ gitSHA := point.ID
+ if len(gitSHA) > 7 {
+ gitSHA = gitSHA[:7]
+ }
+
+ group.commits = append(group.commits, commitEntry{
+ date: point.Date,
+ gitSHA: gitSHA,
+ message: point.Message,
+ })
+
+ // Update flags - if any commit is temporary/task, the group is too
+ if !point.IsLogsOnly {
+ group.isTemporary = true
+ }
+ if point.IsTaskCheckpoint {
+ group.isTask = true
+ }
+ // Update prompt if the group's prompt is empty but this point has one
+ if group.prompt == "" && point.SessionPrompt != "" {
+ group.prompt = point.SessionPrompt
+ }
+ }
+
+ // Sort commits within each group by date (most recent first)
+ for _, group := range groupMap {
+ sort.Slice(group.commits, func(i, j int) bool {
+ return group.commits[i].date.After(group.commits[j].date)
+ })
+ }
+
+ // Build result slice in order, then sort by latest commit
+ result := make([]checkpointGroup, 0, len(order))
+ for _, cpID := range order {
+ result = append(result, *groupMap[cpID])
+ }
+
+ // Sort groups by latest commit timestamp (most recent first)
+ sort.Slice(result, func(i, j int) bool {
+ // Each group's commits are already sorted, so first commit is latest
+ if len(result[i].commits) == 0 {
+ return false
+ }
+ if len(result[j].commits) == 0 {
+ return true
+ }
+ return result[i].commits[0].date.After(result[j].commits[0].date)
+ })
+
+ return result
+}
+
+// formatCheckpointGroup formats a single checkpoint group for display.
+// The list view headline puts the checkpoint ID first (in bold orange),
+// followed by indicators and the prompt — which cascades from
+// SessionPrompt → latest commit message → dimmed `(no prompt recorded)`.
+func formatCheckpointGroup(sb *strings.Builder, group checkpointGroup, styles statusStyles) {
+ cpID := group.checkpointID
+ if len(cpID) > checkpointIDDisplayLength {
+ cpID = cpID[:checkpointIDDisplayLength]
+ }
+
+ // Indicators (Task / temporary). Skip [temporary] when cpID already says so.
+ var indicators []string
+ if group.isTask {
+ indicators = append(indicators, "[Task]")
+ }
+ if group.isTemporary && cpID != "temporary" {
+ indicators = append(indicators, "[temporary]")
+ }
+
+ // Prompt cascade: SessionPrompt → latest commit message → dimmed placeholder.
+ // Quote user prompts; commit subjects render bare.
+ var promptText string
+ var promptIsPlaceholder bool
+ switch {
+ case group.prompt != "":
+ promptText = fmt.Sprintf("%q", strategy.TruncateDescription(group.prompt, maxPromptDisplayLength))
+ case len(group.commits) > 0 && group.commits[0].message != "":
+ promptText = strategy.TruncateDescription(group.commits[0].message, maxPromptDisplayLength)
+ default:
+ promptText = "(no prompt recorded)"
+ promptIsPlaceholder = true
+ }
+ if promptIsPlaceholder {
+ promptText = styles.render(styles.dim, promptText)
+ }
+
+ // Build suffix: "[Task] [temporary] " with two-space separators.
+ parts := append([]string{}, indicators...)
+ parts = append(parts, promptText)
+ suffix := strings.Join(parts, " ")
+
+ sb.WriteString(styles.listIdentityBullet(cpID, suffix))
+
+ // List commits under this checkpoint.
+ for _, commit := range group.commits {
+ dateTimeStr := commit.date.Format("01-02 15:04")
+ message := strategy.TruncateDescription(commit.message, maxMessageDisplayLength)
+ fmt.Fprintf(sb, " %s (%s) %s\n", dateTimeStr, commit.gitSHA, message)
+ }
+}
+
+// countLines counts the number of lines in a byte slice.
+// For JSONL content (where each line ends with \n), this returns the line count.
+// Empty content returns 0.
+func countLines(content []byte) int {
+ if len(content) == 0 {
+ return 0
+ }
+ count := 0
+ for _, b := range content {
+ if b == '\n' {
+ count++
+ }
+ }
+ return count
+}
+
+// transcriptOffset returns the appropriate offset for scoping a transcript.
+// For Claude Code (JSONL), this is the line count. For Gemini (JSON), this is the message count.
+func transcriptOffset(transcriptBytes []byte, agentType types.AgentType) int {
+ switch agentType {
+ case agent.AgentTypeGemini:
+ t, err := geminicli.ParseTranscript(transcriptBytes)
+ if err != nil {
+ return 0
+ }
+ return len(t.Messages)
+ case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
+ return countLines(transcriptBytes)
+ }
+ return countLines(transcriptBytes)
+}
+
+// hasCodeChanges returns true if the commit has changes to non-metadata files.
+// Uses a full tree diff to distinguish code changes from .trace/ metadata-only changes.
+// Returns false only if the commit has a parent AND only modified .trace/ metadata files.
+//
+// WARNING: This is expensive via go-git (resolves many tree/blob objects from packfiles).
+// For list views with many checkpoints, use hasAnyChanges instead.
+func hasCodeChanges(commit *object.Commit) bool {
+ // First commit on shadow branch captures working copy state - always meaningful
+ if commit.NumParents() == 0 {
+ return true
+ }
+
+ parent, err := commit.Parent(0)
+ if err != nil {
+ return true // Can't check, assume meaningful
+ }
+
+ commitTree, err := commit.Tree()
+ if err != nil {
+ return true
+ }
+
+ parentTree, err := parent.Tree()
+ if err != nil {
+ return true
+ }
+
+ changes, err := parentTree.Diff(commitTree)
+ if err != nil {
+ return true
+ }
+
+ // Check if any non-metadata file was changed
+ for _, change := range changes {
+ name := change.To.Name
+ if name == "" {
+ name = change.From.Name
+ }
+ // Skip .trace/ metadata files
+ if !strings.HasPrefix(name, ".trace/") {
+ return true
+ }
+ }
+
+ return false
+}
+
+// hasAnyChanges is a lightweight alternative to hasCodeChanges that compares
+// tree hashes without doing a full diff. Returns true if the commit's tree
+// differs from its parent's tree. This may include metadata-only changes,
+// but is O(1) instead of O(files) — suitable for list views.
+func hasAnyChanges(commit *object.Commit) bool {
+ if commit.NumParents() == 0 {
+ return true
+ }
+ parent, err := commit.Parent(0)
+ if err != nil {
+ return true
+ }
+ return commit.TreeHash != parent.TreeHash
+}
diff --git a/cli/explain_4_test.go b/cli/explain_4_test.go
new file mode 100644
index 0000000..a4ba49f
--- /dev/null
+++ b/cli/explain_4_test.go
@@ -0,0 +1,822 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/lipgloss/v2"
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestListCommittedForExplain_V2Disabled_ReturnsV1Only(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "f.txt"), []byte("x"), 0o644))
+ _, err = wt.Add("f.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("init", &git.CommitOptions{
+ Author: &object.Signature{Name: "T", Email: "t@t.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ v1Store := checkpoint.NewGitStore(repo)
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ ctx := context.Background()
+
+ transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")
+
+ v1ID := id.MustCheckpointID("ccc777888999")
+ require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: v1ID,
+ SessionID: "session-v1",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "T",
+ AuthorEmail: "t@t.com",
+ }))
+
+ // v2 also has a checkpoint, but v2 is disabled — should only see v1.
+ v2ID := id.MustCheckpointID("ddd000111222")
+ require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: v2ID,
+ SessionID: "session-v2",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ AuthorName: "T",
+ AuthorEmail: "t@t.com",
+ }))
+
+ results, err := listCommittedForExplain(ctx, v1Store, v2Store, false)
+ require.NoError(t, err)
+
+ foundIDs := make(map[id.CheckpointID]bool)
+ for _, r := range results {
+ foundIDs[r.CheckpointID] = true
+ }
+ require.True(t, foundIDs[v1ID], "v1 checkpoint should be returned")
+ require.False(t, foundIDs[v2ID], "v2-only checkpoint should NOT appear when v2 is disabled")
+}
+
+func TestFormatCheckpointOutput_Short(t *testing.T) {
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ CheckpointsCount: 3,
+ FilesTouched: []string{"main.go", "util.go"},
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-21-test-session",
+ CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go", "util.go"},
+ CheckpointsCount: 3,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ },
+ Prompts: "Add a new feature",
+ }
+
+ // Default mode: empty commit message (not shown anyway in default mode)
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
+
+ // Should show checkpoint ID
+ if !strings.Contains(output, "abc123def456") {
+ t.Error("expected checkpoint ID in output")
+ }
+ // Should show session ID
+ if !strings.Contains(output, "2026-01-21-test-session") {
+ t.Error("expected session ID in output")
+ }
+ // Should show timestamp
+ if !strings.Contains(output, "2026-01-21") {
+ t.Error("expected timestamp in output")
+ }
+ // Should show token usage (10000 + 5000 = 15000), formatted compactly.
+ if !strings.Contains(output, " tokens 15k") {
+ t.Error("expected token count in output")
+ }
+ // Should show Intent heading (markdown body)
+ if !strings.Contains(output, "## Intent") {
+ t.Errorf("expected '## Intent' heading in no-color output, got:\n%s", output)
+ }
+ // Should show Summary heading with --generate hint affordance
+ if !strings.Contains(output, "## Summary") {
+ t.Errorf("expected '## Summary' heading in no-color output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "trace explain --generate") {
+ t.Errorf("expected --generate hint in summary affordance, got:\n%s", output)
+ }
+ // Should NOT show full file list in default mode
+ if strings.Contains(output, "main.go") {
+ t.Error("default output should not show file list (use --full)")
+ }
+}
+
+func TestFormatCheckpointOutput_Verbose(t *testing.T) {
+ // Transcript with user prompts that match what we expect to see
+ transcriptContent := []byte(`{"type":"user","uuid":"u1","message":{"content":"Add a new feature"}}
+{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"I'll add the feature"}]}}
+{"type":"user","uuid":"u2","message":{"content":"Fix the bug"}}
+{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Fixed it"}]}}
+{"type":"user","uuid":"u3","message":{"content":"Refactor the code"}}
+`)
+
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ CheckpointsCount: 3,
+ FilesTouched: []string{"main.go", "util.go", "config.yaml"},
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-21-test-session",
+ CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go", "util.go", "config.yaml"},
+ CheckpointsCount: 3,
+ CheckpointTranscriptStart: 0, // All content is this checkpoint's
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ },
+ Prompts: "Add a new feature\nFix the bug\nRefactor the code",
+ Transcript: transcriptContent,
+ }
+
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Should show checkpoint ID (like default)
+ if !strings.Contains(output, "abc123def456") {
+ t.Error("expected checkpoint ID in output")
+ }
+ // Should show session ID (like default)
+ if !strings.Contains(output, "2026-01-21-test-session") {
+ t.Error("expected session ID in output")
+ }
+ // Verbose should show files (with backticks in markdown list items)
+ if !strings.Contains(output, "`main.go`") {
+ t.Error("verbose output should show files")
+ }
+ if !strings.Contains(output, "`util.go`") {
+ t.Error("verbose output should show all files")
+ }
+ if !strings.Contains(output, "`config.yaml`") {
+ t.Error("verbose output should show all files")
+ }
+ // Should show "## Files (N)" markdown heading
+ if !strings.Contains(output, "## Files (3)") {
+ t.Errorf("verbose output should have '## Files (3)' heading, got:\n%s", output)
+ }
+ // Verbose should show scoped transcript section
+ if !strings.Contains(output, "Transcript (checkpoint scope)") {
+ t.Error("verbose output should have Transcript (checkpoint scope) section")
+ }
+ if !strings.Contains(output, "Add a new feature") {
+ t.Error("verbose output should show prompts")
+ }
+}
+
+func TestFormatCheckpointOutput_Verbose_NoCommitMessage(t *testing.T) {
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ CheckpointsCount: 1,
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-21-test-session",
+ CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointsCount: 1,
+ },
+ Prompts: "Add a feature",
+ }
+
+ // When commit message is empty, should not show Commit section
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ if strings.Contains(output, " commits") {
+ t.Error("verbose output should not show Commits section when nil (not searched)")
+ }
+}
+
+func TestFormatCheckpointOutput_Full(t *testing.T) {
+ // Use proper transcript format that matches actual Claude transcripts
+ transcriptData := `{"type":"user","message":{"content":"Add a new feature"}}
+{"type":"assistant","message":{"content":[{"type":"text","text":"I'll add that feature for you."}]}}`
+
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ CheckpointsCount: 3,
+ FilesTouched: []string{"main.go", "util.go"},
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-21-test-session",
+ CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go", "util.go"},
+ CheckpointsCount: 3,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 10000,
+ OutputTokens: 5000,
+ },
+ },
+ Prompts: "Add a new feature",
+ Transcript: []byte(transcriptData),
+ }
+
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, true, &bytes.Buffer{})
+
+ // Should show checkpoint ID (like default)
+ if !strings.Contains(output, "abc123def456") {
+ t.Error("expected checkpoint ID in output")
+ }
+ // Full should also include verbose sections (## Files heading)
+ if !strings.Contains(output, "## Files (2)") {
+ t.Errorf("full output should include '## Files (2)' heading, got:\n%s", output)
+ }
+ // Full shows full session transcript (not scoped)
+ if !strings.Contains(output, "Transcript (full session)") {
+ t.Error("full output should have Transcript (full session) section")
+ }
+ // Should contain actual transcript content (parsed format)
+ if !strings.Contains(output, "Add a new feature") {
+ t.Error("full output should show transcript content")
+ }
+ if !strings.Contains(output, "[Assistant]") {
+ t.Error("full output should show assistant messages in parsed transcript")
+ }
+}
+
+func TestFormatCheckpointOutput_WithSummary(t *testing.T) {
+ cpID := id.MustCheckpointID("abc123456789")
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: cpID,
+ FilesTouched: []string{"file1.go", "file2.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: cpID,
+ SessionID: "2026-01-22-test-session",
+ CreatedAt: time.Date(2026, 1, 22, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"file1.go", "file2.go"},
+ Summary: &checkpoint.Summary{
+ Intent: "Implement user authentication",
+ Outcome: "Added login and logout functionality",
+ Learnings: checkpoint.LearningsSummary{
+ Repo: []string{"Uses JWT for auth tokens"},
+ Code: []checkpoint.CodeLearning{{Path: "auth.go", Line: 42, Finding: "Token validation happens here"}},
+ Workflow: []string{"Always run tests after auth changes"},
+ },
+ Friction: []string{"Had to refactor session handling"},
+ OpenItems: []string{"Add password reset flow"},
+ },
+ },
+ Prompts: "Add user authentication",
+ }
+
+ // Test default output (non-verbose) with summary
+ output := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
+
+ // Should show AI-generated intent and outcome as markdown.
+ if !strings.Contains(output, "## Intent\n\nImplement user authentication") {
+ t.Errorf("expected AI intent in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "## Outcome\n\nAdded login and logout functionality") {
+ t.Errorf("expected AI outcome in output, got:\n%s", output)
+ }
+ // Summary markdown includes all generated summary sections.
+ if !strings.Contains(output, "## Learnings") {
+ t.Errorf("summary output should show learnings, got:\n%s", output)
+ }
+
+ // Test verbose output with summary
+ verboseOutput := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Verbose should show learnings sections
+ if !strings.Contains(verboseOutput, "## Learnings") {
+ t.Errorf("verbose output should show learnings, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "### Repository") {
+ t.Errorf("verbose output should show repository learnings, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "Uses JWT for auth tokens") {
+ t.Errorf("verbose output should show repo learning content, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "### Code") {
+ t.Errorf("verbose output should show code learnings, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "`auth.go:42`") {
+ t.Errorf("verbose output should show code learning with line number, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "### Workflow") {
+ t.Errorf("verbose output should show workflow learnings, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "## Friction") {
+ t.Errorf("verbose output should show friction, got:\n%s", verboseOutput)
+ }
+ if !strings.Contains(verboseOutput, "## Open Items") {
+ t.Errorf("verbose output should show open items, got:\n%s", verboseOutput)
+ }
+}
+
+func TestFormatCheckpointOutput_SummaryStartsAfterTightHeaderRule(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("abc123456789")
+ summary := &checkpoint.CheckpointSummary{CheckpointID: cpID}
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: cpID,
+ SessionID: "2026-01-22-test-session",
+ CreatedAt: time.Date(2026, 1, 22, 10, 30, 0, 0, time.UTC),
+ Summary: &checkpoint.Summary{
+ Intent: "Implement user authentication",
+ Outcome: "Added login and logout functionality",
+ },
+ },
+ }
+
+ output := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
+ rule := strings.Repeat("─", 60)
+ want := " created 2026-01-22 10:30:00\n" + rule + "\n## Intent"
+
+ if !strings.Contains(output, want) {
+ t.Fatalf("expected summary to start immediately after header rule, got:\n%s", output)
+ }
+}
+
+func TestBuildSummaryMarkdown_FullSummary(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "Rotate session tokens on logout",
+ Outcome: "Logout now mints a new token",
+ Learnings: checkpoint.LearningsSummary{
+ Repo: []string{"Auth lives behind the auth_v2 gate"},
+ Code: []checkpoint.CodeLearning{
+ {Path: "auth/session.go", Line: 42, Finding: "Rotate before cookie clear"},
+ },
+ Workflow: []string{"Manual curl confirmed the path"},
+ },
+ Friction: []string{"go-git v5 reset deleted .trace"},
+ OpenItems: []string{"Backfill rotation for legacy cookies"},
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ want := "## Intent\n\n" +
+ "Rotate session tokens on logout\n\n" +
+ "## Outcome\n\n" +
+ "Logout now mints a new token\n\n" +
+ "## Learnings\n\n" +
+ "### Repository\n\n" +
+ "- Auth lives behind the auth_v2 gate\n\n" +
+ "### Code\n\n" +
+ "- `auth/session.go:42` — Rotate before cookie clear\n\n" +
+ "### Workflow\n\n" +
+ "- Manual curl confirmed the path\n\n" +
+ "## Friction\n\n" +
+ "- go-git v5 reset deleted .trace\n\n" +
+ "## Open Items\n\n" +
+ "- Backfill rotation for legacy cookies\n"
+
+ if got != want {
+ t.Errorf("buildSummaryMarkdown mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ }
+}
+
+func TestBuildSummaryMarkdown_NoLearnings(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "Trivial fix",
+ Outcome: "Fixed",
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ if strings.Contains(got, "## Learnings") {
+ t.Errorf("expected no Learnings heading when all subsections empty, got:\n%s", got)
+ }
+ if !strings.Contains(got, "## Intent\n\nTrivial fix\n\n") {
+ t.Errorf("expected Intent block, got:\n%s", got)
+ }
+ if !strings.Contains(got, "## Outcome\n\nFixed\n") {
+ t.Errorf("expected Outcome block, got:\n%s", got)
+ }
+}
+
+func TestBuildSummaryMarkdown_PartialLearnings(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "i",
+ Outcome: "o",
+ Learnings: checkpoint.LearningsSummary{
+ Code: []checkpoint.CodeLearning{
+ {Path: "a.go", Finding: "x"},
+ },
+ },
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ if !strings.Contains(got, "## Learnings") {
+ t.Errorf("expected Learnings heading when Code populated, got:\n%s", got)
+ }
+ if !strings.Contains(got, "### Code") {
+ t.Errorf("expected Code subsection, got:\n%s", got)
+ }
+ if strings.Contains(got, "### Repository") {
+ t.Errorf("did not expect Repository subsection, got:\n%s", got)
+ }
+ if strings.Contains(got, "### Workflow") {
+ t.Errorf("did not expect Workflow subsection, got:\n%s", got)
+ }
+}
+
+func TestBuildSummaryMarkdown_CodeLineVariants(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "i",
+ Outcome: "o",
+ Learnings: checkpoint.LearningsSummary{
+ Code: []checkpoint.CodeLearning{
+ {Path: "a.go", Line: 10, EndLine: 20, Finding: "range"},
+ {Path: "b.go", Line: 5, Finding: "single"},
+ {Path: "c.go", Finding: "no-line"},
+ },
+ },
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ wantLines := []string{
+ "- `a.go:10-20` — range",
+ "- `b.go:5` — single",
+ "- `c.go` — no-line",
+ }
+ for _, line := range wantLines {
+ if !strings.Contains(got, line) {
+ t.Errorf("expected line %q in output, got:\n%s", line, got)
+ }
+ }
+}
+
+func TestBuildSummaryMarkdown_EmptyFrictionAndOpenItems(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "i",
+ Outcome: "o",
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ if strings.Contains(got, "## Friction") {
+ t.Errorf("did not expect Friction heading, got:\n%s", got)
+ }
+ if strings.Contains(got, "## Open Items") {
+ t.Errorf("did not expect Open Items heading, got:\n%s", got)
+ }
+}
+
+func TestBuildSummaryMarkdown_BacktickEscape(t *testing.T) {
+ t.Parallel()
+
+ summary := &checkpoint.Summary{
+ Intent: "Use the `foo` command",
+ Outcome: "Wrapped in `bar`",
+ }
+
+ got := buildSummaryMarkdown(summary)
+
+ if strings.Contains(got, "`foo`") {
+ t.Errorf("expected backticks to be neutralized in Intent, got:\n%s", got)
+ }
+ if strings.Contains(got, "`bar`") {
+ t.Errorf("expected backticks to be neutralized in Outcome, got:\n%s", got)
+ }
+ if !strings.Contains(got, "Use the ‘foo‘ command") {
+ t.Errorf("expected U+2018 substitution in Intent, got:\n%s", got)
+ }
+}
+
+func TestBuildSummaryMarkdown_NilSummary(t *testing.T) {
+ t.Parallel()
+
+ if got := buildSummaryMarkdown(nil); got != "" {
+ t.Errorf("expected empty string for nil summary, got %q", got)
+ }
+}
+
+func TestBuildFilesMarkdown_RendersPathsAsInlineCode(t *testing.T) {
+ t.Parallel()
+
+ got := buildFilesMarkdown([]string{
+ "normal.go",
+ "- tricky [path].go",
+ "dir/`quoted`.go",
+ })
+
+ wantLines := []string{
+ "- `normal.go`",
+ "- `- tricky [path].go`",
+ "- `dir/‘quoted‘.go`",
+ }
+ for _, line := range wantLines {
+ if !strings.Contains(got, line) {
+ t.Errorf("expected escaped file line %q in output, got:\n%s", line, got)
+ }
+ }
+}
+
+func TestFormatCheckpointHeader_FullMetadataPlain(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ summary := &checkpoint.CheckpointSummary{
+ TokenUsage: &agent.TokenUsage{InputTokens: 18432},
+ }
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "2026-04-29-7f3c1a",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ commits := []associatedCommit{{
+ ShortSHA: "9f2c11a",
+ Message: "feat(auth): rotate session tokens on logout",
+ Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
+ }}
+ author := checkpoint.Author{Name: "Peyton Montei", Email: "peyton@trace.io"}
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(summary, meta, cpID, commits, author, styles)
+
+ wantLines := []string{
+ "● Checkpoint a3b2c4d5e6f7",
+ " session 2026-04-29-7f3c1a",
+ " created 2026-04-29 14:22:08",
+ " author Peyton Montei ",
+ " tokens 18.4k",
+ " commits 9f2c11a feat(auth): rotate session tokens on logout",
+ }
+ for _, line := range wantLines {
+ if !strings.Contains(got, line) {
+ t.Errorf("expected line %q in header, got:\n%s", line, got)
+ }
+ }
+}
+
+func TestFormatCheckpointHeader_NoAuthor(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
+
+ if strings.Contains(got, " author") {
+ t.Errorf("did not expect author row when Name empty, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_NoCommits(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
+
+ if strings.Contains(got, " commits") {
+ t.Errorf("did not expect commits row when commits is nil, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_MultipleCommits(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ commits := []associatedCommit{
+ {ShortSHA: "aaa1111", Message: "first", Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)},
+ {ShortSHA: "bbb2222", Message: "second", Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)},
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(nil, meta, cpID, commits, checkpoint.Author{}, styles)
+
+ if !strings.Contains(got, " commits (2)") {
+ t.Errorf("expected commits row with count (2), got:\n%s", got)
+ }
+ if !strings.Contains(got, " aaa1111 2026-04-29 first") {
+ t.Errorf("expected first commit line aligned under value column, got:\n%s", got)
+ }
+ if !strings.Contains(got, " bbb2222 2026-04-29 second") {
+ t.Errorf("expected second commit line aligned under value column, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_EmptyCommitsSlice(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(nil, meta, cpID, []associatedCommit{}, checkpoint.Author{}, styles)
+
+ if !strings.Contains(got, " commits (none on this branch)") {
+ t.Errorf("expected explicit none row when commits slice is empty, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_NoTokenUsage(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
+
+ if strings.Contains(got, " tokens") {
+ t.Errorf("did not expect tokens row when both meta and summary are nil, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_TokensFromSummaryFallback(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ TokenUsage: nil,
+ }
+ summary := &checkpoint.CheckpointSummary{
+ TokenUsage: &agent.TokenUsage{InputTokens: 1234},
+ }
+ styles := statusStyles{colorEnabled: false, width: 60}
+
+ got := formatCheckpointHeader(summary, meta, cpID, nil, checkpoint.Author{}, styles)
+
+ if !strings.Contains(got, " tokens 1.2k") {
+ t.Errorf("expected tokens row from summary fallback, got:\n%s", got)
+ }
+}
+
+func TestFormatCheckpointHeader_ColorEnabledRenders(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+ meta := checkpoint.CommittedMetadata{
+ SessionID: "s",
+ CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
+ TokenUsage: &agent.TokenUsage{InputTokens: 1234},
+ }
+ plainStyles := statusStyles{colorEnabled: false, width: 60}
+ colorStyles := statusStyles{
+ colorEnabled: true,
+ width: 60,
+ bold: lipgloss.NewStyle().Bold(true),
+ dim: lipgloss.NewStyle().Faint(true),
+ yellow: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
+ }
+
+ plain := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, plainStyles)
+ styled := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, colorStyles)
+
+ if !strings.Contains(plain, "●") {
+ t.Errorf("expected ● glyph in plain output, got:\n%s", plain)
+ }
+ if !strings.Contains(styled, "●") {
+ t.Errorf("expected ● glyph in styled output, got:\n%s", styled)
+ }
+ if len(styled) <= len(plain) {
+ t.Errorf("expected styled length (%d) > plain length (%d)", len(styled), len(plain))
+ }
+}
+
+func TestBuildPagerCmd_LessRInjectedWhenEnvUnset(t *testing.T) {
+ oldEnv := pagerLookupEnv
+ t.Cleanup(func() { pagerLookupEnv = oldEnv })
+
+ pagerLookupEnv = func(key string) string {
+ if key == pagerEnvVar || key == lessEnvVar {
+ return ""
+ }
+ return os.Getenv(key)
+ }
+
+ cmd, pager := buildPagerCmd(context.Background())
+
+ if runtime.GOOS == windowsGOOS {
+ t.Skip("LESS injection only applies to less on Unix")
+ }
+ if pager != lessPagerName {
+ t.Fatalf("expected resolved pager 'less' on non-Windows, got %q", pager)
+ }
+
+ found := false
+ for _, e := range cmd.Env {
+ if e == lessRawControlEnv {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("expected LESS=-R in cmd.Env")
+ }
+}
+
+func TestBuildPagerCmd_ReplacesEmptyLessEnv(t *testing.T) {
+ t.Setenv(lessEnvVar, "")
+
+ oldEnv := pagerLookupEnv
+ t.Cleanup(func() { pagerLookupEnv = oldEnv })
+
+ pagerLookupEnv = func(key string) string {
+ if key == pagerEnvVar || key == lessEnvVar {
+ return ""
+ }
+ return os.Getenv(key)
+ }
+
+ cmd, pager := buildPagerCmd(context.Background())
+
+ if runtime.GOOS == windowsGOOS {
+ t.Skip("LESS injection only applies to less on Unix")
+ }
+ if pager != lessPagerName {
+ t.Fatalf("expected resolved pager 'less' on non-Windows, got %q", pager)
+ }
+
+ lessEntries := 0
+ for _, e := range cmd.Env {
+ if strings.HasPrefix(e, lessEnvVar+"=") {
+ lessEntries++
+ if e != lessRawControlEnv {
+ t.Errorf("expected %s, got %q", lessRawControlEnv, e)
+ }
+ }
+ }
+ if lessEntries != 1 {
+ t.Errorf("expected exactly one LESS entry, got %d", lessEntries)
+ }
+}
diff --git a/cli/explain_5_test.go b/cli/explain_5_test.go
new file mode 100644
index 0000000..fcc4ff2
--- /dev/null
+++ b/cli/explain_5_test.go
@@ -0,0 +1,768 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildPagerCmd_LessRSkippedWhenLessEnvSet(t *testing.T) {
+ oldEnv := pagerLookupEnv
+ t.Cleanup(func() { pagerLookupEnv = oldEnv })
+
+ pagerLookupEnv = func(key string) string {
+ switch key {
+ case pagerEnvVar:
+ return ""
+ case lessEnvVar:
+ return "-FRX"
+ default:
+ return os.Getenv(key)
+ }
+ }
+
+ cmd, _ := buildPagerCmd(context.Background())
+
+ for _, e := range cmd.Env {
+ if e == lessRawControlEnv {
+ t.Error("did not expect LESS=-R when user set LESS=-FRX")
+ }
+ }
+}
+
+func TestBuildPagerCmd_HonorsCustomPager(t *testing.T) {
+ oldEnv := pagerLookupEnv
+ t.Cleanup(func() { pagerLookupEnv = oldEnv })
+
+ pagerLookupEnv = func(key string) string {
+ if key == pagerEnvVar {
+ return "bat"
+ }
+ return os.Getenv(key)
+ }
+
+ cmd, pager := buildPagerCmd(context.Background())
+
+ if pager != "bat" {
+ t.Errorf("expected resolved pager 'bat', got %q", pager)
+ }
+ for _, e := range cmd.Env {
+ if e == lessRawControlEnv {
+ t.Error("did not expect LESS=-R when user picked a custom pager")
+ }
+ }
+}
+
+func TestFormatBranchCheckpoints_BasicOutput(t *testing.T) {
+ now := time.Now()
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Add feature X",
+ Date: now,
+ CheckpointID: "chk123456789",
+ SessionID: "2026-01-22-session-1",
+ SessionPrompt: "Implement feature X",
+ },
+ {
+ ID: "def456ghi789",
+ Message: "Fix bug in Y",
+ Date: now.Add(-time.Hour),
+ CheckpointID: "chk987654321",
+ SessionID: "2026-01-22-session-2",
+ SessionPrompt: "Fix the bug",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "feature/my-branch", points, "")
+
+ // Should show branch name
+ if !strings.Contains(output, "feature/my-branch") {
+ t.Errorf("expected branch name in output, got:\n%s", output)
+ }
+
+ // Should show checkpoint count (new metadata-row shape)
+ if !strings.Contains(output, "checkpoints 2") {
+ t.Errorf("expected 'checkpoints 2' in output, got:\n%s", output)
+ }
+
+ // Should show checkpoint messages
+ if !strings.Contains(output, "Add feature X") {
+ t.Errorf("expected first checkpoint message in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "Fix bug in Y") {
+ t.Errorf("expected second checkpoint message in output, got:\n%s", output)
+ }
+}
+
+func TestFormatBranchCheckpoints_GroupedByCheckpointID(t *testing.T) {
+ // Create checkpoints spanning multiple days
+ today := time.Date(2026, 1, 22, 10, 0, 0, 0, time.UTC)
+ yesterday := time.Date(2026, 1, 21, 14, 0, 0, 0, time.UTC)
+
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Today checkpoint 1",
+ Date: today,
+ CheckpointID: "chk111111111",
+ SessionID: "2026-01-22-session-1",
+ SessionPrompt: "First task today",
+ },
+ {
+ ID: "def456ghi789",
+ Message: "Today checkpoint 2",
+ Date: today.Add(-30 * time.Minute),
+ CheckpointID: "chk222222222",
+ SessionID: "2026-01-22-session-1",
+ SessionPrompt: "First task today",
+ },
+ {
+ ID: "ghi789jkl012",
+ Message: "Yesterday checkpoint",
+ Date: yesterday,
+ CheckpointID: "chk333333333",
+ SessionID: "2026-01-21-session-2",
+ SessionPrompt: "Task from yesterday",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Should group by checkpoint ID - check for checkpoint headers (identity bullet)
+ if !strings.Contains(output, "● chk111111111") {
+ t.Errorf("expected checkpoint ID header in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "● chk333333333") {
+ t.Errorf("expected checkpoint ID header in output, got:\n%s", output)
+ }
+
+ // Dates should appear inline with commits (format MM-DD)
+ if !strings.Contains(output, "01-22") {
+ t.Errorf("expected today's date inline with commits, got:\n%s", output)
+ }
+ if !strings.Contains(output, "01-21") {
+ t.Errorf("expected yesterday's date inline with commits, got:\n%s", output)
+ }
+
+ // Today's checkpoints should appear before yesterday's (sorted by latest timestamp)
+ todayIdx := strings.Index(output, "chk111111111")
+ yesterdayIdx := strings.Index(output, "chk333333333")
+ if todayIdx == -1 || yesterdayIdx == -1 || todayIdx > yesterdayIdx {
+ t.Errorf("expected today's checkpoints before yesterday's, got:\n%s", output)
+ }
+}
+
+func TestFormatBranchCheckpoints_NoCheckpoints(t *testing.T) {
+ output := formatBranchCheckpoints(io.Discard, "feature/empty-branch", nil, "")
+
+ // Should show branch name
+ if !strings.Contains(output, "feature/empty-branch") {
+ t.Errorf("expected branch name in output, got:\n%s", output)
+ }
+
+ // Should indicate no checkpoints (new metadata-row shape: "checkpoints 0")
+ if !strings.Contains(output, "checkpoints 0") && !strings.Contains(output, "No checkpoints") {
+ t.Errorf("expected indication of no checkpoints, got:\n%s", output)
+ }
+}
+
+func TestFormatBranchCheckpoints_ShowsSessionInfo(t *testing.T) {
+ now := time.Now()
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Test checkpoint",
+ Date: now,
+ CheckpointID: "chk123456789",
+ SessionID: "2026-01-22-test-session",
+ SessionPrompt: "This is my test prompt",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Should show session prompt
+ if !strings.Contains(output, "This is my test prompt") {
+ t.Errorf("expected session prompt in output, got:\n%s", output)
+ }
+}
+
+func TestFormatBranchCheckpoints_ShowsTemporaryIndicator(t *testing.T) {
+ now := time.Now()
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Committed checkpoint",
+ Date: now,
+ CheckpointID: "chk123456789",
+ IsLogsOnly: true, // Committed = logs only, no indicator shown
+ SessionID: "2026-01-22-session-1",
+ },
+ {
+ ID: "def456ghi789",
+ Message: "Active checkpoint",
+ Date: now.Add(-time.Hour),
+ CheckpointID: "chk987654321",
+ IsLogsOnly: false, // Temporary = can be rewound, shows [temporary]
+ SessionID: "2026-01-22-session-1",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Should indicate temporary (non-committed) checkpoints with [temporary]
+ if !strings.Contains(output, "[temporary]") {
+ t.Errorf("expected [temporary] indicator for non-committed checkpoint, got:\n%s", output)
+ }
+
+ // Committed checkpoints should NOT have [temporary] indicator
+ // Find the line with the committed checkpoint and verify it doesn't have [temporary]
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ if strings.Contains(line, "chk123456789") && strings.Contains(line, "[temporary]") {
+ t.Errorf("committed checkpoint should not have [temporary] indicator, got:\n%s", output)
+ }
+ }
+}
+
+func TestFormatBranchCheckpoints_ShowsTaskCheckpoints(t *testing.T) {
+ now := time.Now()
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Running tests (toolu_01ABC)",
+ Date: now,
+ CheckpointID: "chk123456789",
+ IsTaskCheckpoint: true,
+ ToolUseID: "toolu_01ABC",
+ SessionID: "2026-01-22-session-1",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Should indicate task checkpoint
+ if !strings.Contains(output, "[Task]") && !strings.Contains(output, "task") {
+ t.Errorf("expected task checkpoint indicator, got:\n%s", output)
+ }
+}
+
+// TestFormatCheckpointGroup_NoPromptNoCommitShowsPlaceholder verifies the
+// (no prompt recorded) placeholder appears only when neither a session prompt
+// nor a commit message is available.
+func TestFormatCheckpointGroup_NoPromptNoCommitShowsPlaceholder(t *testing.T) {
+ t.Parallel()
+ var sb strings.Builder
+ styles := newStatusStyles(io.Discard)
+ formatCheckpointGroup(&sb, checkpointGroup{
+ checkpointID: "temporary",
+ prompt: "",
+ isTemporary: true,
+ commits: []commitEntry{{date: time.Now(), gitSHA: "deadbee", message: ""}},
+ }, styles)
+ out := sb.String()
+ if !strings.Contains(out, "(no prompt recorded)") {
+ t.Errorf("expected '(no prompt recorded)' placeholder:\n%s", out)
+ }
+}
+
+// TestFormatCheckpointGroup_FallsBackToCommitMessage verifies the cascade:
+// when SessionPrompt is empty but a commit message is present, the headline
+// renders the commit message bare (not the placeholder).
+func TestFormatCheckpointGroup_FallsBackToCommitMessage(t *testing.T) {
+ t.Parallel()
+ var sb strings.Builder
+ styles := newStatusStyles(io.Discard)
+ formatCheckpointGroup(&sb, checkpointGroup{
+ checkpointID: "abc123def456",
+ prompt: "",
+ commits: []commitEntry{{date: time.Now(), gitSHA: "deadbee", message: "feat(cli): wire up paging"}},
+ }, styles)
+ out := sb.String()
+ if !strings.Contains(out, "● abc123def456") {
+ t.Errorf("expected identity bullet headline:\n%s", out)
+ }
+ if !strings.Contains(out, "feat(cli): wire up paging") {
+ t.Errorf("expected commit-message fallback in headline:\n%s", out)
+ }
+ if strings.Contains(out, "(no prompt recorded)") {
+ t.Errorf("did not expect dimmed placeholder when commit message available:\n%s", out)
+ }
+}
+
+func TestFormatBranchCheckpoints_TruncatesLongMessages(t *testing.T) {
+ now := time.Now()
+ longMessage := strings.Repeat("a", 200) // 200 character message
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: longMessage,
+ Date: now,
+ CheckpointID: "chk123456789",
+ SessionID: "2026-01-22-session-1",
+ },
+ }
+
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Output should not contain the full 200 character message
+ if strings.Contains(output, longMessage) {
+ t.Errorf("expected long message to be truncated, got full message in output")
+ }
+
+ // Should contain truncation indicator (usually "...")
+ if !strings.Contains(output, "...") {
+ t.Errorf("expected truncation indicator '...' for long message, got:\n%s", output)
+ }
+}
+
+func TestGetBranchCheckpoints_ReadsPromptFromShadowBranch(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo with an initial commit
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create and commit initial file
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ initialCommit, err := w.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ // Create metadata directory with prompt.txt
+ sessionID := "2026-01-27-test-session"
+ metadataDir := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ expectedPrompt := "This is my test prompt for the checkpoint"
+ if err := os.WriteFile(filepath.Join(metadataDir, paths.PromptFileName), []byte(expectedPrompt), 0o644); err != nil {
+ t.Fatalf("failed to write prompt file: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create first checkpoint (baseline copy) - this one gets filtered out
+ store := checkpoint.NewGitStore(repo)
+ baseCommit := initialCommit.String()[:7]
+ _, err = store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
+ SessionID: sessionID,
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{"test.txt"},
+ MetadataDir: ".trace/metadata/" + sessionID,
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "First checkpoint (baseline)",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: true,
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() first checkpoint error = %v", err)
+ }
+
+ // Modify test file again for a second checkpoint with actual code changes
+ if err := os.WriteFile(testFile, []byte("second modification"), 0o644); err != nil {
+ t.Fatalf("failed to modify test file: %v", err)
+ }
+
+ // Create second checkpoint (has code changes, won't be filtered)
+ _, err = store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
+ SessionID: sessionID,
+ BaseCommit: baseCommit,
+ ModifiedFiles: []string{"test.txt"},
+ MetadataDir: ".trace/metadata/" + sessionID,
+ MetadataDirAbs: metadataDir,
+ CommitMessage: "Second checkpoint with code changes",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ IsFirstCheckpoint: false, // Not first, has parent
+ })
+ if err != nil {
+ t.Fatalf("WriteTemporary() second checkpoint error = %v", err)
+ }
+
+ // Now call getBranchCheckpoints and verify the prompt is read
+ points, err := getBranchCheckpoints(context.Background(), repo, 10)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints() error = %v", err)
+ }
+
+ // Should have at least one temporary checkpoint (the second one with code changes)
+ var foundTempCheckpoint bool
+ for _, point := range points {
+ if !point.IsLogsOnly && point.SessionID == sessionID {
+ foundTempCheckpoint = true
+ // Verify the prompt was read correctly from the shadow branch tree
+ if point.SessionPrompt != expectedPrompt {
+ t.Errorf("expected prompt %q, got %q", expectedPrompt, point.SessionPrompt)
+ }
+ break
+ }
+ }
+
+ if !foundTempCheckpoint {
+ t.Errorf("expected to find temporary checkpoint with session ID %s, got points: %+v", sessionID, points)
+ }
+}
+
+func TestGetCurrentWorktreeHash_MainWorktree(t *testing.T) {
+ // In a temp dir with a real .git directory (main worktree), getCurrentWorktreeHash
+ // should return the hash of empty string (main worktree ID is "").
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+
+ hash := getCurrentWorktreeHash(context.Background())
+ expected := checkpoint.HashWorktreeID("") // Main worktree has empty ID
+ if hash != expected {
+ t.Errorf("getCurrentWorktreeHash(context.Background()) = %q, want %q (hash of empty worktree ID)", hash, expected)
+ }
+}
+
+func TestGetReachableTemporaryCheckpoints_FiltersByWorktree(t *testing.T) {
+ // Shadow branches are namespaced by worktree hash (trace/-).
+ // Only shadow branches matching the current worktree should be included.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Setup metadata for both sessions
+ sessionIDLocal := "2026-02-10-local-session"
+ sessionIDOther := "2026-02-10-other-session"
+ for _, sid := range []string{sessionIDLocal, sessionIDOther} {
+ metaDir := filepath.Join(tmpDir, ".trace", "metadata", sid)
+ if err := os.MkdirAll(metaDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metaDir, paths.PromptFileName), []byte("test"), 0o644); err != nil {
+ t.Fatalf("failed to write prompt: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metaDir, "full.jsonl"), []byte(`{"test":true}`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+ }
+
+ store := checkpoint.NewGitStore(repo)
+ baseCommit := initialCommit.String()[:7]
+
+ writeCheckpoints := func(sessionID, worktreeID string) {
+ t.Helper()
+ metaDirAbs := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
+ // Baseline
+ if _, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
+ SessionID: sessionID, BaseCommit: baseCommit, WorktreeID: worktreeID,
+ ModifiedFiles: []string{"test.txt"}, MetadataDir: ".trace/metadata/" + sessionID,
+ MetadataDirAbs: metaDirAbs, CommitMessage: "baseline", AuthorName: "Test",
+ AuthorEmail: "test@test.com", IsFirstCheckpoint: true,
+ }); err != nil {
+ t.Fatalf("WriteTemporary baseline error: %v", err)
+ }
+ // Code change checkpoint
+ if err := os.WriteFile(testFile, []byte(sessionID+" changes"), 0o644); err != nil {
+ t.Fatalf("failed to modify test file: %v", err)
+ }
+ if _, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
+ SessionID: sessionID, BaseCommit: baseCommit, WorktreeID: worktreeID,
+ ModifiedFiles: []string{"test.txt"}, MetadataDir: ".trace/metadata/" + sessionID,
+ MetadataDirAbs: metaDirAbs, CommitMessage: "code changes", AuthorName: "Test",
+ AuthorEmail: "test@test.com", IsFirstCheckpoint: false,
+ }); err != nil {
+ t.Fatalf("WriteTemporary code changes error: %v", err)
+ }
+ }
+
+ writeCheckpoints(sessionIDLocal, "") // Main worktree (matches test env)
+ writeCheckpoints(sessionIDOther, "other-worktree") // Different worktree
+
+ // getBranchCheckpoints should only include local worktree's checkpoints
+ points, err := getBranchCheckpoints(context.Background(), repo, 20)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints error: %v", err)
+ }
+
+ for _, p := range points {
+ if p.SessionID == sessionIDOther {
+ t.Errorf("found checkpoint from other worktree (session %s) - should be filtered out", sessionIDOther)
+ }
+ }
+ var foundLocal bool
+ for _, p := range points {
+ if p.SessionID == sessionIDLocal {
+ foundLocal = true
+ }
+ }
+ if !foundLocal {
+ t.Errorf("expected local worktree checkpoint (session %s), got: %+v", sessionIDLocal, points)
+ }
+}
+
+// TestRunExplainBranchDefault_ShowsBranchCheckpoints is covered by TestExplainDefault_ShowsBranchView
+// since runExplainDefault now calls runExplainBranchDefault directly.
+
+func TestRunExplainBranchDefault_DetachedHead(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo with a commit
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitHash, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Checkout to detached HEAD state
+ if err := w.Checkout(&git.CheckoutOptions{Hash: commitHash}); err != nil {
+ t.Fatalf("failed to checkout detached HEAD: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ err = runExplainBranchDefault(context.Background(), &stdout, true)
+ // Should NOT error
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+
+ output := stdout.String()
+
+ // Should indicate detached HEAD state in branch name
+ if !strings.Contains(output, "HEAD") && !strings.Contains(output, "detached") {
+ t.Errorf("expected output to indicate detached HEAD state, got: %s", output)
+ }
+}
+
+func TestIsAncestorOf(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("v1"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commit1, err := w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create second commit
+ if err := os.WriteFile(testFile, []byte("v2"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commit2, err := w.Commit("second commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second commit: %v", err)
+ }
+
+ t.Run("commit is ancestor of later commit", func(t *testing.T) {
+ // commit1 should be an ancestor of commit2
+ if !strategy.IsAncestorOf(context.Background(), repo, commit1, commit2) {
+ t.Error("expected commit1 to be ancestor of commit2")
+ }
+ })
+
+ t.Run("commit is not ancestor of earlier commit", func(t *testing.T) {
+ // commit2 should NOT be an ancestor of commit1
+ if strategy.IsAncestorOf(context.Background(), repo, commit2, commit1) {
+ t.Error("expected commit2 to NOT be ancestor of commit1")
+ }
+ })
+
+ t.Run("commit is ancestor of itself", func(t *testing.T) {
+ // A commit should be considered an ancestor of itself
+ if !strategy.IsAncestorOf(context.Background(), repo, commit1, commit1) {
+ t.Error("expected commit to be ancestor of itself")
+ }
+ })
+}
+
+func TestGetBranchCheckpoints_OnFeatureBranch(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit on main
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ // Get checkpoints (should be empty, but shouldn't error)
+ points, err := getBranchCheckpoints(context.Background(), repo, 20)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints() error = %v", err)
+ }
+
+ // Should return empty list (no checkpoints yet)
+ if len(points) != 0 {
+ t.Errorf("expected 0 checkpoints, got %d", len(points))
+ }
+}
+
+func TestHasCodeChanges_FirstCommitReturnsTrue(t *testing.T) {
+ // First commit on a shadow branch (no parent) should return true
+ // since it captures the working copy state - real uncommitted work
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit (has no parent)
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitHash, err := w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ // First commit (no parent) captures working copy state - should return true
+ if !hasCodeChanges(commit) {
+ t.Error("hasCodeChanges() should return true for first commit (captures working copy)")
+ }
+}
diff --git a/cli/explain_6_test.go b/cli/explain_6_test.go
new file mode 100644
index 0000000..c03eed3
--- /dev/null
+++ b/cli/explain_6_test.go
@@ -0,0 +1,781 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/summarize"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/transcript"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHasCodeChanges_OnlyMetadataChanges(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create second commit with only .trace/ metadata changes
+ metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write metadata file: %v", err)
+ }
+ if _, err := w.Add(".trace"); err != nil {
+ t.Fatalf("failed to add .trace: %v", err)
+ }
+ commitHash, err := w.Commit("metadata only commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ // Only .trace/ changes should return false
+ if hasCodeChanges(commit) {
+ t.Error("hasCodeChanges() should return false when only .trace/ files changed")
+ }
+}
+
+func TestHasCodeChanges_WithCodeChanges(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create second commit with code changes
+ if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
+ t.Fatalf("failed to modify test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add modified file: %v", err)
+ }
+ commitHash, err := w.Commit("code change commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ // Code changes should return true
+ if !hasCodeChanges(commit) {
+ t.Error("hasCodeChanges() should return true when code files changed")
+ }
+}
+
+func TestHasCodeChanges_MixedChanges(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create second commit with BOTH code and metadata changes
+ if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
+ t.Fatalf("failed to modify test file: %v", err)
+ }
+ metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write metadata file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ if _, err := w.Add(".trace"); err != nil {
+ t.Fatalf("failed to add .trace: %v", err)
+ }
+ commitHash, err := w.Commit("mixed changes commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ // Mixed changes should return true (code changes present)
+ if !hasCodeChanges(commit) {
+ t.Error("hasCodeChanges() should return true when commit has both code and metadata changes")
+ }
+}
+
+func TestGetBranchCheckpoints_FiltersMainCommits(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit on master (go-git default)
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ mainCommit, err := w.Commit("main commit with Trace-Checkpoint: abc123def456", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to create main commit: %v", err)
+ }
+
+ // Create feature branch
+ featureBranch := "feature/test"
+ if err := w.Checkout(&git.CheckoutOptions{
+ Hash: mainCommit,
+ Branch: plumbing.NewBranchReferenceName(featureBranch),
+ Create: true,
+ }); err != nil {
+ t.Fatalf("failed to create feature branch: %v", err)
+ }
+
+ // Create commit on feature branch
+ if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("feature commit with Trace-Checkpoint: def456ghi789", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to create feature commit: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ // Get checkpoints - should only include feature branch commits, not main
+ // Note: Without actual checkpoint data in trace/checkpoints/v1, this returns empty
+ // but the important thing is it doesn't error and the filtering logic runs
+ points, err := getBranchCheckpoints(context.Background(), repo, 20)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints() error = %v", err)
+ }
+
+ // Without checkpoint data (no trace/checkpoints/v1 branch), should return 0 checkpoints
+ // This validates the filtering code path runs without error
+ if len(points) != 0 {
+ t.Errorf("expected 0 checkpoints without checkpoint data, got %d", len(points))
+ }
+}
+
+func TestScopeTranscriptForCheckpoint_SlicesTranscript(t *testing.T) {
+ // Transcript with 5 lines - prompts 1, 2, 3 with their responses
+ fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"prompt 1"}}
+{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"response 1"}]}}
+{"type":"user","uuid":"u2","message":{"content":"prompt 2"}}
+{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"response 2"}]}}
+{"type":"user","uuid":"u3","message":{"content":"prompt 3"}}
+`)
+
+ // Checkpoint starts at line 2 (after prompt 1 and response 1)
+ // Should only include lines 2-4 (prompt 2, response 2, prompt 3)
+ scoped := scopeTranscriptForCheckpoint(fullTranscript, 2, agent.AgentTypeClaudeCode)
+
+ // Parse the scoped transcript to verify content
+ lines, err := transcript.ParseFromBytes(scoped)
+ if err != nil {
+ t.Fatalf("failed to parse scoped transcript: %v", err)
+ }
+
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 lines in scoped transcript, got %d", len(lines))
+ }
+
+ // First line should be prompt 2 (u2), not prompt 1
+ if lines[0].UUID != "u2" {
+ t.Errorf("expected first line to be u2 (prompt 2), got %s", lines[0].UUID)
+ }
+
+ // Last line should be prompt 3 (u3)
+ if lines[2].UUID != "u3" {
+ t.Errorf("expected last line to be u3 (prompt 3), got %s", lines[2].UUID)
+ }
+}
+
+func TestScopeTranscriptForCheckpoint_ZeroLinesReturnsAll(t *testing.T) {
+ transcriptData := []byte(`{"type":"user","uuid":"u1","message":{"content":"prompt 1"}}
+{"type":"user","uuid":"u2","message":{"content":"prompt 2"}}
+`)
+
+ // With linesAtStart=0, should return full transcript
+ scoped := scopeTranscriptForCheckpoint(transcriptData, 0, agent.AgentTypeClaudeCode)
+
+ lines, err := transcript.ParseFromBytes(scoped)
+ if err != nil {
+ t.Fatalf("failed to parse scoped transcript: %v", err)
+ }
+
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines with linesAtStart=0, got %d", len(lines))
+ }
+}
+
+func TestScopeTranscriptForCheckpoint_CodexUsesStoredLineOffsets(t *testing.T) {
+ t.Parallel()
+
+ fullTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
+{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions"}]}}
+{"timestamp":"t3","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md\ninstructions"}]}}
+{"timestamp":"t4","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first prompt"}]}}
+{"timestamp":"t5","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to first"}]}}
+{"timestamp":"t6","type":"event_msg","payload":{"type":"token_count","input_tokens":10,"output_tokens":1}}
+{"timestamp":"t7","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second prompt"}]}}
+{"timestamp":"t8","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to second"}]}}
+`)
+
+ scoped := scopeTranscriptForCheckpoint(fullTranscript, 6, agent.AgentTypeCodex)
+ entries, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(scoped), agent.AgentTypeCodex)
+ if err != nil {
+ t.Fatalf("failed to build condensed transcript: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Fatalf("expected 2 scoped entries, got %d", len(entries))
+ }
+
+ if entries[0].Type != summarize.EntryTypeUser || entries[0].Content != "second prompt" {
+ t.Fatalf("expected first entry to be second prompt, got %#v", entries[0])
+ }
+
+ if entries[1].Type != summarize.EntryTypeAssistant || entries[1].Content != "response to second" {
+ t.Fatalf("expected second entry to be second response, got %#v", entries[1])
+ }
+}
+
+func TestExtractPromptsFromScopedTranscript(t *testing.T) {
+ // Transcript with 4 lines - 2 user prompts, 2 assistant responses
+ transcript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt"}}
+{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
+{"type":"user","uuid":"u2","message":{"content":"Second prompt"}}
+{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
+`)
+
+ prompts := extractPromptsFromTranscript(transcript, "")
+
+ if len(prompts) != 2 {
+ t.Fatalf("expected 2 prompts, got %d", len(prompts))
+ }
+
+ if prompts[0] != "First prompt" {
+ t.Errorf("expected first prompt 'First prompt', got %q", prompts[0])
+ }
+
+ if prompts[1] != "Second prompt" {
+ t.Errorf("expected second prompt 'Second prompt', got %q", prompts[1])
+ }
+}
+
+func TestFormatCheckpointOutput_UsesScopedPrompts(t *testing.T) {
+ // Full transcript with 4 lines (2 prompts + 2 responses)
+ // Checkpoint starts at line 2 (should only show second prompt)
+ fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt - should NOT appear"}}
+{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
+{"type":"user","uuid":"u2","message":{"content":"Second prompt - SHOULD appear"}}
+{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
+`)
+
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-30-test-session",
+ CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 2, // Checkpoint starts at line 2
+ },
+ Prompts: "First prompt - should NOT appear\nSecond prompt - SHOULD appear", // Full prompts (not scoped yet)
+ Transcript: fullTranscript,
+ }
+
+ // Verbose output should use scoped prompts
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Should show ONLY the second prompt (scoped)
+ if !strings.Contains(output, "Second prompt - SHOULD appear") {
+ t.Errorf("expected scoped prompt in output, got:\n%s", output)
+ }
+
+ // Should NOT show the first prompt (it's before this checkpoint's scope)
+ if strings.Contains(output, "First prompt - should NOT appear") {
+ t.Errorf("expected first prompt to be excluded from scoped output, got:\n%s", output)
+ }
+}
+
+func TestFormatCheckpointOutput_FallsBackToStoredPrompts(t *testing.T) {
+ // Test backwards compatibility: when no transcript exists, use stored prompts
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-30-test-session",
+ CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 0,
+ },
+ Prompts: "Stored prompt from older checkpoint",
+ Transcript: nil, // No transcript available
+ }
+
+ // Verbose output should fall back to stored prompts
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Intent should use stored prompt
+ if !strings.Contains(output, "Stored prompt from older checkpoint") {
+ t.Errorf("expected fallback to stored prompts, got:\n%s", output)
+ }
+}
+
+func TestFormatCheckpointOutput_FullShowsTraceTranscript(t *testing.T) {
+ // Test that --full mode shows the trace transcript, not scoped
+ fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt"}}
+{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
+{"type":"user","uuid":"u2","message":{"content":"Second prompt"}}
+{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
+`)
+
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-30-test-session",
+ CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 2, // Checkpoint starts at line 2
+ },
+ Transcript: fullTranscript,
+ }
+
+ // Full mode should show the ENTIRE transcript (not scoped)
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, true, &bytes.Buffer{})
+
+ // Should show the full transcript including first prompt (even though scoped prompts exclude it)
+ if !strings.Contains(output, "First prompt") {
+ t.Errorf("expected --full to show trace transcript including first prompt, got:\n%s", output)
+ }
+ if !strings.Contains(output, "Second prompt") {
+ t.Errorf("expected --full to show trace transcript including second prompt, got:\n%s", output)
+ }
+}
+
+func TestRunExplainCommit_NoCheckpointTrailer(t *testing.T) {
+ // Create test repo with a commit that has no Trace-Checkpoint trailer
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ // Create a commit without checkpoint trailer
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ hash, err := w.Commit("Regular commit without trailer", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ var buf bytes.Buffer
+ err = runExplainCommit(context.Background(), &buf, &buf, hash.String()[:7], false, false, false, false, false, false, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "✗ No associated Trace checkpoint") {
+ t.Errorf("expected styled failure block, got: %s", output)
+ }
+ if !strings.Contains(output, " reason") {
+ t.Errorf("expected reason row, got: %s", output)
+ }
+}
+
+func TestRunExplainCommit_WithCheckpointTrailer(t *testing.T) {
+ // Create test repo with a commit that has an Trace-Checkpoint trailer
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ // Create a commit with checkpoint trailer
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+
+ // Create commit with checkpoint trailer
+ checkpointID := "abc123def456"
+ commitMsg := "Feature commit\n\nTrace-Checkpoint: " + checkpointID + "\n"
+ hash, err := w.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ var buf bytes.Buffer
+ // This should try to look up the checkpoint and fail (checkpoint doesn't exist in store)
+ // but it should still attempt the lookup rather than showing commit details
+ err = runExplainCommit(context.Background(), &buf, &buf, hash.String()[:7], false, false, false, false, false, false, false)
+
+ // Should error because the checkpoint doesn't exist in the store
+ if err == nil {
+ t.Fatalf("expected error for missing checkpoint in store, got nil")
+ }
+
+ // Error should mention checkpoint not found
+ if !strings.Contains(err.Error(), "checkpoint not found") && !strings.Contains(err.Error(), "abc123def456") {
+ t.Errorf("expected error about checkpoint not found, got: %v", err)
+ }
+}
+
+func TestFormatBranchCheckpoints_SessionFilter(t *testing.T) {
+ now := time.Now()
+ points := []strategy.RewindPoint{
+ {
+ ID: "abc123def456",
+ Message: "Checkpoint from session 1",
+ Date: now,
+ CheckpointID: "chk111111111",
+ SessionID: "2026-01-22-session-alpha",
+ SessionPrompt: "Task for session alpha",
+ },
+ {
+ ID: "def456ghi789",
+ Message: "Checkpoint from session 2",
+ Date: now.Add(-time.Hour),
+ CheckpointID: "chk222222222",
+ SessionID: "2026-01-22-session-beta",
+ SessionPrompt: "Task for session beta",
+ },
+ {
+ ID: "ghi789jkl012",
+ Message: "Another checkpoint from session 1",
+ Date: now.Add(-2 * time.Hour),
+ CheckpointID: "chk333333333",
+ SessionID: "2026-01-22-session-alpha",
+ SessionPrompt: "Another task for session alpha",
+ },
+ }
+
+ t.Run("no filter shows all checkpoints", func(t *testing.T) {
+ output := formatBranchCheckpoints(io.Discard, "main", points, "")
+
+ // Should show all checkpoints (new metadata-row shape)
+ if !strings.Contains(output, "checkpoints 3") {
+ t.Errorf("expected 'checkpoints 3' in output, got:\n%s", output)
+ }
+ // Should show prompts from both sessions
+ if !strings.Contains(output, "Task for session alpha") {
+ t.Errorf("expected alpha session prompt in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "Task for session beta") {
+ t.Errorf("expected beta session prompt in output, got:\n%s", output)
+ }
+ })
+
+ t.Run("filter by exact session ID", func(t *testing.T) {
+ output := formatBranchCheckpoints(io.Discard, "main", points, "2026-01-22-session-alpha")
+
+ // Should show only alpha checkpoints (2 of them)
+ if !strings.Contains(output, "checkpoints 2") {
+ t.Errorf("expected 'checkpoints 2' in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "Task for session alpha") {
+ t.Errorf("expected alpha session prompt in output, got:\n%s", output)
+ }
+ // Should NOT contain beta session prompt
+ if strings.Contains(output, "Task for session beta") {
+ t.Errorf("expected output to NOT contain beta session prompt, got:\n%s", output)
+ }
+ // Should show filter info as a metadata row (label aligned to widest "checkpoints")
+ if !strings.Contains(output, "session 2026-01-22-session-alpha") {
+ t.Errorf("expected 'session ... 2026-01-22-session-alpha' in output, got:\n%s", output)
+ }
+ })
+
+ t.Run("filter by session ID prefix", func(t *testing.T) {
+ output := formatBranchCheckpoints(io.Discard, "main", points, "2026-01-22-session-b")
+
+ // Should show only beta checkpoint (1)
+ if !strings.Contains(output, "checkpoints 1") {
+ t.Errorf("expected 'checkpoints 1' in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "Task for session beta") {
+ t.Errorf("expected beta session prompt in output, got:\n%s", output)
+ }
+ })
+
+ t.Run("filter with no matches", func(t *testing.T) {
+ output := formatBranchCheckpoints(io.Discard, "main", points, "nonexistent-session")
+
+ // Should show 0 checkpoints
+ if !strings.Contains(output, "checkpoints 0") {
+ t.Errorf("expected 'checkpoints 0' in output, got:\n%s", output)
+ }
+ // Should show filter info even with no matches (label aligned to widest "checkpoints")
+ if !strings.Contains(output, "session nonexistent-session") {
+ t.Errorf("expected 'session ... nonexistent-session' in output, got:\n%s", output)
+ }
+ })
+}
+
+func TestRunExplain_SessionFlagFiltersListView(t *testing.T) {
+ // Test that --session alone (without --checkpoint or --commit) filters the list view.
+ // This is a unit test for the routing logic.
+ // Use a fresh git repo so we don't walk the real repo's shadow branches (which is slow).
+ tmp := t.TempDir()
+ for _, args := range [][]string{
+ {"init"},
+ {"config", "user.email", "test@test.com"},
+ {"config", "user.name", "Test User"},
+ {"commit", "--allow-empty", "-m", "init"},
+ } {
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = tmp
+ cmd.Env = testutil.GitIsolatedEnv()
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ t.Chdir(tmp)
+
+ var buf, errBuf bytes.Buffer
+
+ // When session is specified alone, it should NOT error for mutual exclusivity
+ // It should route to the list view with a filter (which may fail for other reasons
+ // like not being in a git repo, but not for mutual exclusivity)
+ err := runExplain(context.Background(), &buf, &errBuf, "some-session", "", "", "", false, false, false, false, false, false, false)
+
+ // Should NOT be a mutual exclusivity error
+ if err != nil && strings.Contains(err.Error(), "cannot specify multiple") {
+ t.Errorf("--session alone should not trigger mutual exclusivity error, got: %v", err)
+ }
+}
+
+func TestRunExplain_SessionWithCheckpointStillMutuallyExclusive(t *testing.T) {
+ // Test that --session with --checkpoint is still an error
+ var buf, errBuf bytes.Buffer
+
+ err := runExplain(context.Background(), &buf, &errBuf, "some-session", "", "some-checkpoint", "", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error when --session and --checkpoint both specified")
+ }
+ if !strings.Contains(err.Error(), "cannot specify multiple") {
+ t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
+ }
+}
+
+func TestRunExplain_SessionWithCommitStillMutuallyExclusive(t *testing.T) {
+ // Test that --session with --commit is still an error
+ var buf, errBuf bytes.Buffer
+
+ err := runExplain(context.Background(), &buf, &errBuf, "some-session", "some-commit", "", "", false, false, false, false, false, false, false)
+
+ if err == nil {
+ t.Error("expected error when --session and --commit both specified")
+ }
+ if !strings.Contains(err.Error(), "cannot specify multiple") {
+ t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
+ }
+}
+
+func TestFormatCheckpointOutput_WithAuthor(t *testing.T) {
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-30-test-session",
+ CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 0,
+ },
+ Prompts: "Add a new feature",
+ Transcript: nil, // No transcript available
+ }
+
+ author := checkpoint.Author{
+ Name: "Alice Developer",
+ Email: "alice@example.com",
+ }
+
+ // With author, should show author line
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, author, true, false, &bytes.Buffer{})
+
+ if !strings.Contains(output, " author Alice Developer ") {
+ t.Errorf("expected author line in output, got:\n%s", output)
+ }
+}
+
+func TestFormatCheckpointOutput_EmptyAuthor(t *testing.T) {
+ // Test backwards compatibility: when no transcript exists, use stored prompts
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-01-30-test-session",
+ CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 0,
+ },
+ Prompts: "Add a new feature",
+ Transcript: nil, // No transcript available
+ }
+
+ // Empty author - should not show author line
+ author := checkpoint.Author{}
+
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, author, true, false, &bytes.Buffer{})
+
+ if strings.Contains(output, " author") {
+ t.Errorf("expected no author line for empty author, got:\n%s", output)
+ }
+}
diff --git a/cli/explain_7_test.go b/cli/explain_7_test.go
new file mode 100644
index 0000000..4e8100a
--- /dev/null
+++ b/cli/explain_7_test.go
@@ -0,0 +1,754 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetAssociatedCommits(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ checkpointID := id.MustCheckpointID("abc123def456")
+
+ // Create first commit without checkpoint trailer
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now().Add(-2 * time.Hour),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create commit with matching checkpoint trailer
+ if err := os.WriteFile(testFile, []byte("with checkpoint"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitMsg := trailers.FormatCheckpoint("feat: add feature", checkpointID)
+ _, err = w.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Alice Developer",
+ Email: "alice@example.com",
+ When: time.Now().Add(-1 * time.Hour),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create checkpoint commit: %v", err)
+ }
+
+ // Create another commit without checkpoint trailer
+ if err := os.WriteFile(testFile, []byte("after checkpoint"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("unrelated commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create unrelated commit: %v", err)
+ }
+
+ // Test: should find the one commit with matching checkpoint
+ commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits error: %v", err)
+ }
+
+ if len(commits) != 1 {
+ t.Fatalf("expected 1 associated commit, got %d", len(commits))
+ }
+
+ commit := commits[0]
+ if commit.Author != "Alice Developer" {
+ t.Errorf("expected author 'Alice Developer', got %q", commit.Author)
+ }
+ if !strings.Contains(commit.Message, "feat: add feature") {
+ t.Errorf("expected message to contain 'feat: add feature', got %q", commit.Message)
+ }
+ if len(commit.ShortSHA) != 7 {
+ t.Errorf("expected 7-char short SHA, got %d chars: %q", len(commit.ShortSHA), commit.ShortSHA)
+ }
+ if len(commit.SHA) != 40 {
+ t.Errorf("expected 40-char full SHA, got %d chars", len(commit.SHA))
+ }
+}
+
+func TestGetAssociatedCommits_NoMatches(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create commit without checkpoint trailer
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("regular commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ // Search for a checkpoint ID that doesn't exist (valid format: 12 hex chars)
+ checkpointID := id.MustCheckpointID("aaaa11112222")
+ commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits error: %v", err)
+ }
+
+ if len(commits) != 0 {
+ t.Errorf("expected 0 associated commits, got %d", len(commits))
+ }
+}
+
+func TestGetAssociatedCommits_MultipleMatches(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Initialize git repo
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ checkpointID := id.MustCheckpointID("abc123def456")
+
+ // Create initial commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now().Add(-3 * time.Hour),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create first commit with checkpoint trailer
+ if err := os.WriteFile(testFile, []byte("first"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitMsg := trailers.FormatCheckpoint("first checkpoint commit", checkpointID)
+ _, err = w.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now().Add(-2 * time.Hour),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create first checkpoint commit: %v", err)
+ }
+
+ // Create second commit with same checkpoint trailer (e.g., amend scenario)
+ if err := os.WriteFile(testFile, []byte("second"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitMsg = trailers.FormatCheckpoint("second checkpoint commit", checkpointID)
+ _, err = w.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now().Add(-1 * time.Hour),
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create second checkpoint commit: %v", err)
+ }
+
+ // Test: should find both commits with matching checkpoint
+ commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits error: %v", err)
+ }
+
+ if len(commits) != 2 {
+ t.Fatalf("expected 2 associated commits, got %d", len(commits))
+ }
+
+ // Should be in reverse chronological order (newest first)
+ if !strings.Contains(commits[0].Message, "second") {
+ t.Errorf("expected newest commit first, got %q", commits[0].Message)
+ }
+ if !strings.Contains(commits[1].Message, "first") {
+ t.Errorf("expected older commit second, got %q", commits[1].Message)
+ }
+}
+
+func TestFormatCheckpointOutput_WithAssociatedCommits(t *testing.T) {
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-02-04-test-session",
+ CreatedAt: time.Date(2026, 2, 4, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 0,
+ },
+ Prompts: "Add a new feature",
+ Transcript: nil, // No transcript available
+ }
+
+ associatedCommits := []associatedCommit{
+ {
+ SHA: "abc123def4567890abc123def4567890abc12345",
+ ShortSHA: "abc123d",
+ Message: "feat: add feature",
+ Author: "Alice Developer",
+ Date: time.Date(2026, 2, 4, 11, 0, 0, 0, time.UTC),
+ },
+ {
+ SHA: "def456abc7890123def456abc7890123def45678",
+ ShortSHA: "def456a",
+ Message: "fix: update feature",
+ Author: "Bob Developer",
+ Date: time.Date(2026, 2, 4, 12, 0, 0, 0, time.UTC),
+ },
+ }
+
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), associatedCommits, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Should show commits section with count
+ if !strings.Contains(output, " commits (2)") {
+ t.Errorf("expected 'Commits: (2)' in output, got:\n%s", output)
+ }
+ // Should show commit details
+ if !strings.Contains(output, "abc123d") {
+ t.Errorf("expected short SHA 'abc123d' in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "def456a") {
+ t.Errorf("expected short SHA 'def456a' in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "feat: add feature") {
+ t.Errorf("expected commit message in output, got:\n%s", output)
+ }
+ if !strings.Contains(output, "fix: update feature") {
+ t.Errorf("expected commit message in output, got:\n%s", output)
+ }
+ // Should show date in format YYYY-MM-DD
+ if !strings.Contains(output, "2026-02-04") {
+ t.Errorf("expected date in output, got:\n%s", output)
+ }
+}
+
+// createMergeCommit creates a merge commit with two parents using go-git plumbing APIs.
+// Returns the merge commit hash.
+func createMergeCommit(t *testing.T, repo *git.Repository, parent1, parent2 plumbing.Hash, treeHash plumbing.Hash, message string) plumbing.Hash {
+ t.Helper()
+
+ sig := object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now(),
+ }
+ commit := object.Commit{
+ Author: sig,
+ Committer: sig,
+ Message: message,
+ TreeHash: treeHash,
+ ParentHashes: []plumbing.Hash{parent1, parent2},
+ }
+ obj := repo.Storer.NewEncodedObject()
+ if err := commit.Encode(obj); err != nil {
+ t.Fatalf("failed to encode merge commit: %v", err)
+ }
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ t.Fatalf("failed to store merge commit: %v", err)
+ }
+ return hash
+}
+
+func TestGetBranchCheckpoints_WithMergeFromMain(t *testing.T) {
+ // Regression test: when main is merged into a feature branch, getBranchCheckpoints
+ // should still find feature branch checkpoints from before the merge.
+ // The old repo.Log() approach did a full DAG walk, entering main's history through
+ // merge commits and eventually hitting consecutiveMainLimit, silently dropping
+ // older feature branch checkpoints.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit on master
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create feature branch from initial commit
+ featureBranch := plumbing.NewBranchReferenceName("feature/test")
+ if err := w.Checkout(&git.CheckoutOptions{
+ Hash: initialCommit,
+ Branch: featureBranch,
+ Create: true,
+ }); err != nil {
+ t.Fatalf("failed to create feature branch: %v", err)
+ }
+
+ // Create first feature checkpoint commit (BEFORE the merge)
+ cpID1 := id.MustCheckpointID("aaa111bbb222")
+ if err := os.WriteFile(testFile, []byte("feature work 1"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ featureCommit1, err := w.Commit(trailers.FormatCheckpoint("feat: first feature", cpID1), &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first feature commit: %v", err)
+ }
+
+ // Switch to master and add commits (simulating work on main)
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: plumbing.NewBranchReferenceName("master"),
+ }); err != nil {
+ t.Fatalf("failed to checkout master: %v", err)
+ }
+ if err := os.WriteFile(testFile, []byte("main work"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ mainCommit, err := w.Commit("main: add work", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create main commit: %v", err)
+ }
+
+ // Switch back to feature branch
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: featureBranch,
+ }); err != nil {
+ t.Fatalf("failed to checkout feature branch: %v", err)
+ }
+
+ // Create merge commit: merge main into feature (feature is first parent, main is second parent)
+ featureCommitObj, commitObjErr := repo.CommitObject(featureCommit1)
+ if commitObjErr != nil {
+ t.Fatalf("failed to get feature commit object: %v", commitObjErr)
+ }
+ featureTree, treeErr := featureCommitObj.Tree()
+ if treeErr != nil {
+ t.Fatalf("failed to get feature commit tree: %v", treeErr)
+ }
+ mergeHash := createMergeCommit(t, repo, featureCommit1, mainCommit, featureTree.Hash, "Merge branch 'master' into feature/test")
+
+ // Update feature branch ref to point to merge commit
+ ref := plumbing.NewHashReference(featureBranch, mergeHash)
+ if err := repo.Storer.SetReference(ref); err != nil {
+ t.Fatalf("failed to update feature branch ref: %v", err)
+ }
+
+ // Reset worktree to merge commit
+ if err := w.Reset(&git.ResetOptions{Commit: mergeHash, Mode: git.HardReset}); err != nil {
+ t.Fatalf("failed to reset to merge: %v", err)
+ }
+
+ // Create second feature checkpoint commit (AFTER the merge)
+ cpID2 := id.MustCheckpointID("ccc333ddd444")
+ if err := os.WriteFile(testFile, []byte("feature work 2"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit(trailers.FormatCheckpoint("feat: second feature", cpID2), &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-1 * time.Hour)},
+ Parents: []plumbing.Hash{mergeHash},
+ Committer: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-1 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second feature commit: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ // Test getAssociatedCommits - should find BOTH feature checkpoint commits
+ // by walking first-parent chain (skipping the merge's second parent into main)
+ commits1, err := getAssociatedCommits(context.Background(), repo, cpID1, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits for cpID1 error: %v", err)
+ }
+ if len(commits1) != 1 {
+ t.Errorf("expected 1 commit for cpID1 (first feature checkpoint), got %d", len(commits1))
+ }
+
+ commits2, err := getAssociatedCommits(context.Background(), repo, cpID2, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits for cpID2 error: %v", err)
+ }
+ if len(commits2) != 1 {
+ t.Errorf("expected 1 commit for cpID2 (second feature checkpoint), got %d", len(commits2))
+ }
+}
+
+func TestGetBranchCheckpoints_MergeCommitAtHEAD(t *testing.T) {
+ // Test that when HEAD itself is a merge commit, walkFirstParentCommits
+ // correctly follows the first parent (feature branch history) and
+ // doesn't walk into the second parent (main branch history).
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit on master
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create feature branch
+ featureBranch := plumbing.NewBranchReferenceName("feature/merge-at-head")
+ if err := w.Checkout(&git.CheckoutOptions{
+ Hash: initialCommit,
+ Branch: featureBranch,
+ Create: true,
+ }); err != nil {
+ t.Fatalf("failed to create feature branch: %v", err)
+ }
+
+ // Create feature checkpoint commit
+ cpID := id.MustCheckpointID("eee555fff666")
+ if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ featureCommit, err := w.Commit(trailers.FormatCheckpoint("feat: feature work", cpID), &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create feature commit: %v", err)
+ }
+
+ // Switch to master and add a commit
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: plumbing.NewBranchReferenceName("master"),
+ }); err != nil {
+ t.Fatalf("failed to checkout master: %v", err)
+ }
+ mainFile := filepath.Join(tmpDir, "main.txt")
+ if err := os.WriteFile(mainFile, []byte("main work"), 0o644); err != nil {
+ t.Fatalf("failed to write main file: %v", err)
+ }
+ if _, err := w.Add("main.txt"); err != nil {
+ t.Fatalf("failed to add main file: %v", err)
+ }
+ mainCommit, err := w.Commit("main: add work", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-2 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create main commit: %v", err)
+ }
+
+ // Switch back to feature and create merge commit AT HEAD
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: featureBranch,
+ }); err != nil {
+ t.Fatalf("failed to checkout feature branch: %v", err)
+ }
+
+ featureCommitObj, commitObjErr := repo.CommitObject(featureCommit)
+ if commitObjErr != nil {
+ t.Fatalf("failed to get feature commit object: %v", commitObjErr)
+ }
+ featureTree, treeErr := featureCommitObj.Tree()
+ if treeErr != nil {
+ t.Fatalf("failed to get feature commit tree: %v", treeErr)
+ }
+ mergeHash := createMergeCommit(t, repo, featureCommit, mainCommit, featureTree.Hash, "Merge branch 'master' into feature/merge-at-head")
+
+ // Update feature branch ref to merge commit (HEAD IS the merge)
+ ref := plumbing.NewHashReference(featureBranch, mergeHash)
+ if err := repo.Storer.SetReference(ref); err != nil {
+ t.Fatalf("failed to update feature branch ref: %v", err)
+ }
+
+ // Create .trace directory
+ if err := os.MkdirAll(".trace", 0o750); err != nil {
+ t.Fatalf("failed to create .trace dir: %v", err)
+ }
+
+ // HEAD is the merge commit itself.
+ // getAssociatedCommits should walk: merge -> featureCommit -> initial
+ // and find the checkpoint on featureCommit.
+ commits, err := getAssociatedCommits(context.Background(), repo, cpID, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits error: %v", err)
+ }
+ if len(commits) != 1 {
+ t.Fatalf("expected 1 associated commit when HEAD is merge commit, got %d", len(commits))
+ }
+ if !strings.Contains(commits[0].Message, "feat: feature work") {
+ t.Errorf("expected feature commit message, got %q", commits[0].Message)
+ }
+}
+
+func TestWalkFirstParentCommits_SkipsMergeParents(t *testing.T) {
+ // Verify that walkFirstParentCommits follows only first parents and doesn't
+ // enter the second parent (merge source) of merge commits.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit (shared ancestor)
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ initialCommit, err := w.Commit("A: initial", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create feature branch with one commit
+ featureBranch := plumbing.NewBranchReferenceName("feature/walk-test")
+ if err := w.Checkout(&git.CheckoutOptions{
+ Hash: initialCommit,
+ Branch: featureBranch,
+ Create: true,
+ }); err != nil {
+ t.Fatalf("failed to create feature branch: %v", err)
+ }
+ if err := os.WriteFile(testFile, []byte("feature"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ featureCommit, err := w.Commit("B: feature work", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create feature commit: %v", err)
+ }
+
+ // Create main branch commit (will be merge source)
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: plumbing.NewBranchReferenceName("master"),
+ }); err != nil {
+ t.Fatalf("failed to checkout master: %v", err)
+ }
+ mainFile := filepath.Join(tmpDir, "main.txt")
+ if err := os.WriteFile(mainFile, []byte("main"), 0o644); err != nil {
+ t.Fatalf("failed to write main file: %v", err)
+ }
+ if _, err := w.Add("main.txt"); err != nil {
+ t.Fatalf("failed to add main file: %v", err)
+ }
+ mainCommit, err := w.Commit("C: main work", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create main commit: %v", err)
+ }
+
+ // Switch to feature and create merge commit
+ if err := w.Checkout(&git.CheckoutOptions{
+ Branch: featureBranch,
+ }); err != nil {
+ t.Fatalf("failed to checkout feature: %v", err)
+ }
+ featureCommitObj, commitObjErr := repo.CommitObject(featureCommit)
+ if commitObjErr != nil {
+ t.Fatalf("failed to get feature commit object: %v", commitObjErr)
+ }
+ featureTree, treeErr := featureCommitObj.Tree()
+ if treeErr != nil {
+ t.Fatalf("failed to get feature commit tree: %v", treeErr)
+ }
+ mergeHash := createMergeCommit(t, repo, featureCommit, mainCommit, featureTree.Hash, "M: merge main into feature")
+
+ // Walk should visit: M (merge) -> B (feature) -> A (initial)
+ // It should NOT visit C (main work), because that's the second parent of the merge.
+ var visited []string
+ err = walkFirstParentCommits(context.Background(), repo, mergeHash, 0, func(c *object.Commit) error {
+ visited = append(visited, strings.Split(c.Message, "\n")[0])
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walkFirstParentCommits error: %v", err)
+ }
+
+ expected := []string{"M: merge main into feature", "B: feature work", "A: initial"}
+ if len(visited) != len(expected) {
+ t.Fatalf("expected %d commits visited, got %d: %v", len(expected), len(visited), visited)
+ }
+ for i, msg := range expected {
+ if visited[i] != msg {
+ t.Errorf("commit %d: expected %q, got %q", i, msg, visited[i])
+ }
+ }
+
+ // Verify C was NOT visited
+ for _, msg := range visited {
+ if strings.Contains(msg, "C: main work") {
+ t.Error("walkFirstParentCommits visited main branch commit (second parent of merge) - should only follow first parents")
+ }
+ }
+}
+
+func TestFormatCheckpointOutput_NoCommitsOnBranch(t *testing.T) {
+ summary := &checkpoint.CheckpointSummary{
+ CheckpointID: id.MustCheckpointID("abc123def456"),
+ FilesTouched: []string{"main.go"},
+ }
+ content := &checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ CheckpointID: "abc123def456",
+ SessionID: "2026-02-04-test-session",
+ CreatedAt: time.Date(2026, 2, 4, 10, 30, 0, 0, time.UTC),
+ FilesTouched: []string{"main.go"},
+ CheckpointTranscriptStart: 0,
+ },
+ Prompts: "Add a new feature",
+ Transcript: nil, // No transcript available
+ }
+
+ // No associated commits - use empty slice (not nil) to indicate "searched but found none"
+ associatedCommits := []associatedCommit{}
+
+ output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), associatedCommits, checkpoint.Author{}, true, false, &bytes.Buffer{})
+
+ // Should show message indicating no commits found
+ if !strings.Contains(output, " commits (none on this branch)") {
+ t.Errorf("expected 'Commits: No commits found on this branch' in output, got:\n%s", output)
+ }
+}
diff --git a/cli/explain_8_test.go b/cli/explain_8_test.go
new file mode 100644
index 0000000..ebf8f0b
--- /dev/null
+++ b/cli/explain_8_test.go
@@ -0,0 +1,778 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetAssociatedCommits_SearchAllFindsMergedBranchCommits(t *testing.T) {
+ // Regression test: --search-all should find checkpoint commits that live on
+ // a feature branch merged into main via a true merge commit. These commits
+ // are on the second parent of the merge, so first-parent-only traversal
+ // won't find them — but --search-all should use full DAG walk.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ checkpointID := id.MustCheckpointID("aabb11223344")
+
+ // Create initial commit on main
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ mainBase, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create a "feature branch" commit with checkpoint trailer (will become second parent)
+ if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ featureMsg := trailers.FormatCheckpoint("feat: add feature", checkpointID)
+ featureCommit, err := w.Commit(featureMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Feature Dev", Email: "dev@example.com", When: time.Now().Add(-3 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create feature commit: %v", err)
+ }
+
+ // Move HEAD back to mainBase to simulate being on main
+ // Create a new commit on "main" that diverges
+ if err := os.WriteFile(testFile, []byte("main work"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ mainCommitObj, err := repo.CommitObject(mainBase)
+ if err != nil {
+ t.Fatalf("failed to get main base commit: %v", err)
+ }
+ mainTree, err := mainCommitObj.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // Create a second main commit (to diverge from feature)
+ mainTip := createCommitWithTree(t, repo, mainTree.Hash, []plumbing.Hash{mainBase}, "main: parallel work")
+
+ // Create merge commit: first parent = mainTip, second parent = featureCommit
+ featureCommitObj, err := repo.CommitObject(featureCommit)
+ if err != nil {
+ t.Fatalf("failed to get feature commit: %v", err)
+ }
+ featureTree, err := featureCommitObj.Tree()
+ if err != nil {
+ t.Fatalf("failed to get feature tree: %v", err)
+ }
+ mergeHash := createMergeCommit(t, repo, mainTip, featureCommit, featureTree.Hash, "Merge feature into main")
+
+ // Point HEAD at merge commit
+ ref := plumbing.NewHashReference("refs/heads/main", mergeHash)
+ if err := repo.Storer.SetReference(ref); err != nil {
+ t.Fatalf("failed to set HEAD: %v", err)
+ }
+ headRef := plumbing.NewSymbolicReference("HEAD", "refs/heads/main")
+ if err := repo.Storer.SetReference(headRef); err != nil {
+ t.Fatalf("failed to set HEAD: %v", err)
+ }
+
+ // Without --search-all (first-parent only): should NOT find the feature commit
+ // because it's on the second parent of the merge
+ commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits error: %v", err)
+ }
+ if len(commits) != 0 {
+ t.Errorf("expected 0 commits without --search-all (first-parent only), got %d", len(commits))
+ }
+
+ // With --search-all (full DAG walk): SHOULD find the feature commit
+ commits, err = getAssociatedCommits(context.Background(), repo, checkpointID, true)
+ if err != nil {
+ t.Fatalf("getAssociatedCommits --search-all error: %v", err)
+ }
+ if len(commits) != 1 {
+ t.Fatalf("expected 1 commit with --search-all, got %d", len(commits))
+ }
+ if commits[0].Author != "Feature Dev" {
+ t.Errorf("expected author 'Feature Dev', got %q", commits[0].Author)
+ }
+}
+
+func TestGetBranchCheckpoints_DefaultBranchFindsMergedCheckpoints(t *testing.T) {
+ // Regression test: on the default branch, getBranchCheckpoints should find
+ // checkpoint commits that came in via merge commits (second parents).
+ // First-parent-only traversal would miss these.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit on master (this is the default branch)
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ masterBase, err := w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create a feature branch commit with checkpoint trailer
+ cpID := id.MustCheckpointID("fea112233344")
+ if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ featureCommit, err := w.Commit(trailers.FormatCheckpoint("feat: add feature", cpID), &git.CommitOptions{
+ Author: &object.Signature{Name: "Feature Dev", Email: "dev@example.com", When: time.Now().Add(-3 * time.Hour)},
+ })
+ if err != nil {
+ t.Fatalf("failed to create feature commit: %v", err)
+ }
+
+ // Get tree hashes for creating commits via plumbing
+ masterBaseObj, err := repo.CommitObject(masterBase)
+ if err != nil {
+ t.Fatalf("failed to get master base: %v", err)
+ }
+ masterTree, err := masterBaseObj.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+ featureObj, err := repo.CommitObject(featureCommit)
+ if err != nil {
+ t.Fatalf("failed to get feature commit: %v", err)
+ }
+ featureTree, err := featureObj.Tree()
+ if err != nil {
+ t.Fatalf("failed to get feature tree: %v", err)
+ }
+
+ // Create a second commit on master (diverge from feature)
+ masterTip := createCommitWithTree(t, repo, masterTree.Hash, []plumbing.Hash{masterBase}, "main: parallel work")
+
+ // Create merge commit on master: first parent = masterTip, second parent = featureCommit
+ mergeHash := createMergeCommit(t, repo, masterTip, featureCommit, featureTree.Hash, "Merge feature into master")
+
+ // Point master at merge commit
+ ref := plumbing.NewHashReference("refs/heads/master", mergeHash)
+ if err := repo.Storer.SetReference(ref); err != nil {
+ t.Fatalf("failed to set ref: %v", err)
+ }
+ headRef := plumbing.NewSymbolicReference("HEAD", "refs/heads/master")
+ if err := repo.Storer.SetReference(headRef); err != nil {
+ t.Fatalf("failed to set HEAD: %v", err)
+ }
+
+ // Write committed checkpoint metadata so getBranchCheckpoints can find it
+ store := checkpoint.NewGitStore(repo)
+ if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "test-session",
+ Strategy: "manual-commit",
+ FilesTouched: []string{"test.txt"},
+ Prompts: []string{"add feature"},
+ }); err != nil {
+ t.Fatalf("failed to write committed checkpoint: %v", err)
+ }
+
+ // getBranchCheckpoints on master should find the checkpoint from the merged feature branch
+ points, err := getBranchCheckpoints(context.Background(), repo, 100)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints error: %v", err)
+ }
+
+ // Should find at least the checkpoint from the merged feature branch
+ var found bool
+ for _, p := range points {
+ if p.CheckpointID == cpID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected to find checkpoint %s from merged feature branch on default branch, got %d points: %v", cpID, len(points), points)
+ }
+}
+
+func TestGetBranchCheckpoints_ReadsPromptFromCommittedCheckpoint(t *testing.T) {
+ // Verifies that getBranchCheckpoints populates RewindPoint.SessionPrompt
+ // from prompt.txt on trace/checkpoints/v1 (committed checkpoint) without
+ // needing to read/parse the full transcript.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create a checkpoint ID and write committed checkpoint with prompt data
+ cpID, err := id.NewCheckpointID("aabb11223344")
+ if err != nil {
+ t.Fatalf("failed to create checkpoint ID: %v", err)
+ }
+
+ expectedPrompt := "Refactor the authentication module to use JWT tokens"
+ store := checkpoint.NewGitStore(repo)
+ if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "2026-02-27-test-session",
+ Strategy: "manual-commit",
+ FilesTouched: []string{"auth.go"},
+ Prompts: []string{expectedPrompt},
+ }); err != nil {
+ t.Fatalf("WriteCommitted() error = %v", err)
+ }
+
+ // Create a user commit with the Trace-Checkpoint trailer
+ if err := os.WriteFile(testFile, []byte("updated with auth changes"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitMsg := trailers.FormatCheckpoint("Refactor auth module", cpID)
+ _, err = w.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit with checkpoint trailer: %v", err)
+ }
+
+ // Call getBranchCheckpoints and verify prompt is populated
+ points, err := getBranchCheckpoints(context.Background(), repo, 10)
+ if err != nil {
+ t.Fatalf("getBranchCheckpoints() error = %v", err)
+ }
+
+ var foundCommitted bool
+ for _, p := range points {
+ if p.CheckpointID == cpID {
+ foundCommitted = true
+ if !p.IsLogsOnly {
+ t.Error("expected committed checkpoint to have IsLogsOnly=true")
+ }
+ if p.SessionPrompt != expectedPrompt {
+ t.Errorf("expected SessionPrompt = %q, got %q", expectedPrompt, p.SessionPrompt)
+ }
+ break
+ }
+ }
+
+ if !foundCommitted {
+ t.Errorf("expected to find committed checkpoint %s, got %d points", cpID, len(points))
+ }
+}
+
+func TestGetBranchCheckpoints_V2OnlyCheckpointDiscoverable(t *testing.T) {
+ // When the v1 metadata branch doesn't exist but v2 has the checkpoint,
+ // getBranchCheckpoints should still find committed checkpoints.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("initial"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Enable v2 via settings.
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ cpID := id.MustCheckpointID("dd11ee22ff33")
+ expectedPrompt := "Create the v2-only checkpoint test file"
+
+ // Write checkpoint ONLY to v2 store.
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ require.NoError(t, v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-v2-only",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
+ Prompts: []string{expectedPrompt},
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // Create a user commit with the Trace-Checkpoint trailer.
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("updated"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ commitMsg := trailers.FormatCheckpoint("Create v2 test file", cpID)
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Verify no v1 metadata branch exists.
+ _, v1Err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.Error(t, v1Err, "v1 metadata branch should not exist")
+
+ // getBranchCheckpoints should find the v2-only checkpoint.
+ points, err := getBranchCheckpoints(context.Background(), repo, 10)
+ require.NoError(t, err)
+
+ var found bool
+ for _, p := range points {
+ if p.CheckpointID == cpID {
+ found = true
+ require.Equal(t, expectedPrompt, p.SessionPrompt,
+ "prompt should be read from v2 /main when v1 is absent")
+ break
+ }
+ }
+ require.True(t, found, "v2-only checkpoint should be discoverable in branch listing")
+}
+
+func TestGetBranchCheckpoints_V2PromptFallbackWhenV1Deleted(t *testing.T) {
+ // When v2 is preferred and v1 metadata branch is deleted after dual-write,
+ // prompts should still be readable from v2 /main.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("initial"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
+ 0o644,
+ ))
+
+ cpID := id.MustCheckpointID("aa11bb22cc33")
+ expectedPrompt := "Dual-write prompt visible after v1 deletion"
+
+ // Dual-write: checkpoint in both v1 and v2.
+ v1Store := checkpoint.NewGitStore(repo)
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ require.NoError(t, v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
+ Prompts: []string{expectedPrompt},
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+ require.NoError(t, v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-dual",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
+ Prompts: []string{expectedPrompt},
+ AuthorName: "Test",
+ AuthorEmail: "test@example.com",
+ }))
+
+ // Create user commit with checkpoint trailer.
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("updated"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ commitMsg := trailers.FormatCheckpoint("Dual-write commit", cpID)
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Delete the v1 metadata branch to simulate it being unavailable.
+ require.NoError(t, repo.Storer.RemoveReference(plumbing.NewBranchReferenceName(paths.MetadataBranchName)))
+
+ // getBranchCheckpoints should still find the checkpoint and read prompt from v2.
+ points, err := getBranchCheckpoints(context.Background(), repo, 10)
+ require.NoError(t, err)
+
+ var found bool
+ for _, p := range points {
+ if p.CheckpointID == cpID {
+ found = true
+ require.Equal(t, expectedPrompt, p.SessionPrompt,
+ "prompt should be read from v2 /main after v1 deletion")
+ break
+ }
+ }
+ require.True(t, found, "checkpoint should be discoverable after v1 branch deletion")
+}
+
+func TestResolvePromptTree_PrefersV2WhenEnabled(t *testing.T) {
+ t.Parallel()
+
+ v1 := &object.Tree{}
+ v2 := &object.Tree{}
+
+ require.Same(t, v2, resolvePromptTree(v1, v2, true), "should prefer v2 when enabled")
+ require.Same(t, v1, resolvePromptTree(v1, v2, false), "should prefer v1 when v2 disabled")
+ require.Same(t, v1, resolvePromptTree(v1, nil, true), "should fall back to v1 when v2 is nil")
+ require.Same(t, v2, resolvePromptTree(nil, v2, false), "should use v2 as last resort when v1 is nil")
+ require.Nil(t, resolvePromptTree(nil, nil, true), "should return nil when both are nil")
+}
+
+func TestHasAnyChanges_FirstCommitReturnsTrue(t *testing.T) {
+ // First commit (no parent) should always return true
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ commitHash, err := w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ if !hasAnyChanges(commit) {
+ t.Error("hasAnyChanges() should return true for first commit (no parent)")
+ }
+}
+
+func TestHasAnyChanges_MetadataOnlyChangeReturnsTrue(t *testing.T) {
+ // Unlike hasCodeChanges, hasAnyChanges uses tree hash comparison and
+ // does not filter out .trace/ metadata files. A metadata-only change
+ // should return true because the tree hash differs from the parent's.
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ testutil.InitRepo(t, tmpDir)
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ _, err = w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create second commit with only .trace/ metadata changes
+ metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
+ if err := os.MkdirAll(metadataDir, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
+ t.Fatalf("failed to write metadata file: %v", err)
+ }
+ if _, err := w.Add(".trace"); err != nil {
+ t.Fatalf("failed to add .trace: %v", err)
+ }
+ commitHash, err := w.Commit("metadata only commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create second commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit object: %v", err)
+ }
+
+ // hasAnyChanges compares tree hashes, so metadata-only changes DO count
+ // (unlike hasCodeChanges which filters .trace/ files)
+ if !hasAnyChanges(commit) {
+ t.Error("hasAnyChanges() should return true for metadata-only changes (tree hash differs)")
+ }
+}
+
+func TestHasAnyChanges_NoOpTreeChangeReturnsFalse(t *testing.T) {
+ // When a commit has the same tree hash as its parent (no-op commit),
+ // hasAnyChanges should return false
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainInit(tmpDir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ w, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create first commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := w.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add test file: %v", err)
+ }
+ firstHash, err := w.Commit("first commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to create first commit: %v", err)
+ }
+
+ // Create a second commit with the exact same tree (allow-empty equivalent)
+ firstCommit, err := repo.CommitObject(firstHash)
+ if err != nil {
+ t.Fatalf("failed to get first commit: %v", err)
+ }
+
+ sig := object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now(),
+ }
+ emptyCommit := object.Commit{
+ Author: sig,
+ Committer: sig,
+ Message: "no-op commit with same tree",
+ TreeHash: firstCommit.TreeHash,
+ ParentHashes: []plumbing.Hash{firstHash},
+ }
+ obj := repo.Storer.NewEncodedObject()
+ if err := emptyCommit.Encode(obj); err != nil {
+ t.Fatalf("failed to encode commit: %v", err)
+ }
+ secondHash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ t.Fatalf("failed to store commit: %v", err)
+ }
+
+ secondCommit, err := repo.CommitObject(secondHash)
+ if err != nil {
+ t.Fatalf("failed to get second commit: %v", err)
+ }
+
+ // Same tree hash as parent → no changes
+ if hasAnyChanges(secondCommit) {
+ t.Error("hasAnyChanges() should return false when tree hash matches parent (no-op commit)")
+ }
+}
+
+// createCommitWithTree creates a commit with a specific tree and parent hashes.
+func createCommitWithTree(t *testing.T, repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) plumbing.Hash {
+ t.Helper()
+ sig := object.Signature{
+ Name: "Test",
+ Email: "test@example.com",
+ When: time.Now(),
+ }
+ commit := object.Commit{
+ Author: sig,
+ Committer: sig,
+ Message: message,
+ TreeHash: treeHash,
+ ParentHashes: parents,
+ }
+ obj := repo.Storer.NewEncodedObject()
+ if err := commit.Encode(obj); err != nil {
+ t.Fatalf("failed to encode commit: %v", err)
+ }
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ if err != nil {
+ t.Fatalf("failed to store commit: %v", err)
+ }
+ return hash
+}
+
+func TestExtractIntent_PrefersScopedPrompt(t *testing.T) {
+ t.Parallel()
+ got := extractIntent([]string{"add explain --generate flag", "later prompt"}, "fallback prompt\nline2")
+ want := "add explain --generate flag"
+ if got != want {
+ t.Errorf("extractIntent scoped\n got: %q\nwant: %q", got, want)
+ }
+}
+
+func TestExtractIntent_FallsBackToFirstLineOfContent(t *testing.T) {
+ t.Parallel()
+ got := extractIntent(nil, "first content line\nsecond line")
+ want := "first content line"
+ if got != want {
+ t.Errorf("extractIntent fallback\n got: %q\nwant: %q", got, want)
+ }
+}
+
+func TestExtractIntent_EmptyReturnsEmpty(t *testing.T) {
+ t.Parallel()
+ if got := extractIntent(nil, ""); got != "" {
+ t.Errorf("extractIntent empty: got %q want empty", got)
+ }
+ if got := extractIntent([]string{""}, ""); got != "" {
+ t.Errorf("extractIntent empty-string-prompt: got %q want empty", got)
+ }
+}
+
+func TestExtractIntent_TruncatesLongPrompts(t *testing.T) {
+ t.Parallel()
+ long := strings.Repeat("a", 500)
+ got := extractIntent([]string{long}, "")
+ if len(got) >= len(long) {
+ t.Errorf("expected truncation; got %d chars", len(got))
+ }
+}
+
+func TestBuildNoSummaryMarkdown_IntentAndAffordance(t *testing.T) {
+ t.Parallel()
+ got := buildNoSummaryMarkdown("add explain --generate flag", nil, "Run `trace explain --generate abc`.")
+ if !strings.Contains(got, "## Intent\n\nadd explain --generate flag\n") {
+ t.Fatalf("missing intent section:\n%s", got)
+ }
+ // escapeSummaryText replaces every backtick with U+2018 (‘), so both
+ // backticks in "Run `trace explain --generate abc`." map to ‘.
+ if !strings.Contains(got, "## Summary\n\n*Run ‘trace explain --generate abc‘.*\n") {
+ t.Fatalf("missing italic summary affordance:\n%s", got)
+ }
+ if strings.Contains(got, "## Files") {
+ t.Fatalf("did not expect Files when files=nil:\n%s", got)
+ }
+}
+
+func TestBuildNoSummaryMarkdown_RendersFilesWhenProvided(t *testing.T) {
+ t.Parallel()
+ got := buildNoSummaryMarkdown("intent", []string{"a.go", "b.go"}, "hint")
+ if !strings.Contains(got, "## Files (2)\n\n- `a.go`\n- `b.go`\n") {
+ t.Fatalf("expected Files section with count and list:\n%s", got)
+ }
+}
+
+func TestBuildNoSummaryMarkdown_EmptyIntentShowsPlaceholder(t *testing.T) {
+ t.Parallel()
+ got := buildNoSummaryMarkdown("", nil, "hint")
+ if !strings.Contains(got, "## Intent\n\n*(no prompt recorded)*\n") {
+ t.Fatalf("expected italic placeholder:\n%s", got)
+ }
+}
+
+func TestRenderExplainBody_NoColorReturnsRawMarkdown(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer // not a TTY → shouldUseColor false
+ got := renderExplainBody(&buf, "## Intent\n\nfoo\n")
+ if got != "## Intent\n\nfoo\n" {
+ t.Errorf("expected raw markdown when no color\n got: %q", got)
+ }
+}
diff --git a/cli/explain_test.go b/cli/explain_test.go
index 4cb712f..5f05b88 100644
--- a/cli/explain_test.go
+++ b/cli/explain_test.go
@@ -5,27 +5,21 @@ import (
"context"
"errors"
"fmt"
- "io"
"os"
- "os/exec"
"path/filepath"
- "runtime"
"strings"
"testing"
"time"
- "charm.land/lipgloss/v2"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/claudecode"
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/strategy"
"github.com/GrayCodeAI/trace/cli/summarize"
"github.com/GrayCodeAI/trace/cli/testutil"
"github.com/GrayCodeAI/trace/cli/trailers"
- "github.com/GrayCodeAI/trace/cli/transcript"
"github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
@@ -784,5371 +778,3 @@ func TestGenerateCheckpointAISummary_AddsDefaultTimeoutWithoutParentDeadline(t *
t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout)
}
}
-
-func TestMaybeCompactExternalTranscriptForSummary_RedactsExternalOutput(t *testing.T) {
- // Cannot use t.Parallel() because external agent discovery mutates the
- // package-level agent registry and this test changes cwd/PATH.
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- ctx := context.Background()
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- t.Chdir(tmpDir)
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled":true,"external_agents":true}`),
- 0o644,
- ))
-
- const (
- name = "summary-redact"
- kind = types.AgentType("Summary Redact Agent")
- secret = "q9Xv2Lm8Rt1Yp4Kd7Wz0Hs6Nc3Bf5Jg"
- )
- externalDir := t.TempDir()
- script := `#!/bin/sh
-case "$1" in
- info)
- echo '{"protocol_version":1,"name":"` + name + `","type":"` + string(kind) + `","description":"External redaction test agent","is_preview":false,"protected_dirs":[],"hook_names":[],"capabilities":{"hooks":false,"transcript_analyzer":false,"transcript_preparer":false,"token_calculator":false,"compact_transcript":true,"text_generator":false,"hook_response_writer":false,"subagent_aware_extractor":false}}'
- ;;
- compact-transcript)
- echo '{"transcript":"eyJ2IjoxLCJhZ2VudCI6InN1bW1hcnktcmVkYWN0IiwiY2xpX3ZlcnNpb24iOiJ0ZXN0IiwidHlwZSI6InVzZXIiLCJ0cyI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwiY29udGVudCI6W3sidGV4dCI6ImtleT1xOVh2MkxtOFJ0MVlwNEtkN1d6MEhzNk5jM0JmNUpnIn1dfQo="}'
- ;;
- *)
- echo '{}'
- ;;
-esac
-`
- require.NoError(t, os.WriteFile(filepath.Join(externalDir, "trace-agent-"+name), []byte(script), 0o755))
- t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- got := maybeCompactExternalTranscriptForSummary(ctx, []byte("not-json"), kind)
- if strings.Contains(string(got), secret) {
- t.Fatalf("external compact transcript was not redacted: %s", got)
- }
- if !strings.Contains(string(got), redact.RedactedPlaceholder) {
- t.Fatalf("expected redacted compact transcript, got: %s", got)
- }
-}
-
-func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testing.T) {
- tmpTimeout := checkpointSummaryTimeout
- tmpGenerator := generateTranscriptSummary
- t.Cleanup(func() {
- checkpointSummaryTimeout = tmpTimeout
- generateTranscriptSummary = tmpGenerator
- })
-
- checkpointSummaryTimeout = 30 * time.Second
-
- parentCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
- defer cancel()
- parentDeadline, _ := parentCtx.Deadline()
-
- var gotDeadline time.Time
- generateTranscriptSummary = func(
- ctx context.Context,
- _ redact.RedactedBytes,
- _ []string,
- _ types.AgentType,
- _ summarize.Generator,
- ) (*checkpoint.Summary, error) {
- gotDeadline, _ = ctx.Deadline()
- <-ctx.Done()
- return nil, ctx.Err()
- }
-
- _, appliedDeadline, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
- if err == nil {
- t.Fatal("expected timeout error")
- }
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("expected DeadlineExceeded, got %v", err)
- }
- if gotDeadline.IsZero() {
- t.Fatal("expected deadline to be captured")
- }
- // The applied deadline must reflect the shorter parent-ctx deadline,
- // not the package-default checkpointSummaryTimeout. Otherwise
- // formatCheckpointSummaryError would report the wrong timeout to users.
- if appliedDeadline >= checkpointSummaryTimeout {
- t.Fatalf("appliedDeadline = %s; want shorter than %s (parent had tighter deadline)",
- appliedDeadline, checkpointSummaryTimeout)
- }
- if delta := gotDeadline.Sub(parentDeadline); delta < -5*time.Millisecond || delta > 5*time.Millisecond {
- t.Fatalf("deadline delta = %s, want near 0", delta)
- }
- if strings.Contains(err.Error(), "30s") {
- t.Fatalf("timeout error should not report default timeout when parent deadline fired: %v", err)
- }
-}
-
-// TestGenerateCheckpointAISummary_PreservesClaudeErrorWhenCtxIsDone guards
-// against the race where the underlying summarizer returns a typed
-// *ClaudeError AND the context happens to be done. Prior code checked
-// timeoutCtx.Err() and unconditionally wrapped with %w context.DeadlineExceeded,
-// which discarded the typed error and routed the user to the wrong
-// "safety deadline" guidance instead of the auth/rate-limit message.
-func TestGenerateCheckpointAISummary_PreservesClaudeErrorWhenCtxIsDone(t *testing.T) {
- tmpTimeout := checkpointSummaryTimeout
- tmpGenerator := generateTranscriptSummary
- t.Cleanup(func() {
- checkpointSummaryTimeout = tmpTimeout
- generateTranscriptSummary = tmpGenerator
- })
-
- checkpointSummaryTimeout = 30 * time.Second
-
- // Cancel the parent before we even call — ctx.Err() will be non-nil.
- parentCtx, cancel := context.WithCancel(context.Background())
- cancel()
-
- claudeErr := &claudecode.ClaudeError{Kind: claudecode.ClaudeErrorAuth, Message: "Invalid API key"}
- generateTranscriptSummary = func(
- context.Context,
- redact.RedactedBytes,
- []string,
- types.AgentType,
- summarize.Generator,
- ) (*checkpoint.Summary, error) {
- return nil, claudeErr
- }
-
- _, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
- var ce *claudecode.ClaudeError
- if !errors.As(err, &ce) {
- t.Fatalf("errors.As did not recover *ClaudeError; got %v", err)
- }
- if ce.Kind != claudecode.ClaudeErrorAuth {
- t.Errorf("Kind = %v; want auth", ce.Kind)
- }
-}
-
-func TestGenerateCheckpointAISummary_ClampsLongParentDeadlineToDefaultTimeout(t *testing.T) {
- tmpTimeout := checkpointSummaryTimeout
- tmpGenerator := generateTranscriptSummary
- t.Cleanup(func() {
- checkpointSummaryTimeout = tmpTimeout
- generateTranscriptSummary = tmpGenerator
- })
-
- checkpointSummaryTimeout = 50 * time.Millisecond
-
- parentCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
- defer cancel()
-
- var gotDeadline time.Time
- generateTranscriptSummary = func(
- ctx context.Context,
- _ redact.RedactedBytes,
- _ []string,
- _ types.AgentType,
- _ summarize.Generator,
- ) (*checkpoint.Summary, error) {
- deadline, ok := ctx.Deadline()
- if !ok {
- return nil, errors.New("expected deadline on summary context")
- }
- gotDeadline = deadline
- return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil
- }
-
- start := time.Now()
- summary, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
- if err != nil {
- t.Fatalf("generateCheckpointAISummary() error = %v", err)
- }
- if summary == nil {
- t.Fatal("expected summary")
- }
- if gotDeadline.IsZero() {
- t.Fatal("expected deadline to be set")
- }
- if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond {
- t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout)
- }
-}
-
-func TestGenerateCheckpointAISummary_UsesCancellationSentinel(t *testing.T) {
- tmpTimeout := checkpointSummaryTimeout
- tmpGenerator := generateTranscriptSummary
- t.Cleanup(func() {
- checkpointSummaryTimeout = tmpTimeout
- generateTranscriptSummary = tmpGenerator
- })
-
- parentCtx, cancel := context.WithCancel(context.Background())
-
- generateTranscriptSummary = func(
- ctx context.Context,
- _ redact.RedactedBytes,
- _ []string,
- _ types.AgentType,
- _ summarize.Generator,
- ) (*checkpoint.Summary, error) {
- cancel()
- <-ctx.Done()
- return nil, ctx.Err()
- }
-
- _, _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode, nil)
- if err == nil {
- t.Fatal("expected cancellation error")
- }
- if !errors.Is(err, context.Canceled) {
- t.Fatalf("expected Canceled, got %v", err)
- }
- if !strings.Contains(err.Error(), "canceled") {
- t.Fatalf("expected cancellation message, got %v", err)
- }
-}
-
-func TestExplainCommit_NotFound(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
-
- var stdout bytes.Buffer
- err := runExplainCommit(context.Background(), &stdout, &stdout, "nonexistent", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error for nonexistent commit, got nil")
- }
- if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "resolve") {
- t.Errorf("expected 'not found' or 'resolve' in error, got: %v", err)
- }
-}
-
-func TestExplainCommit_NoTraceData(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create a commit without Trace metadata
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitHash, err := w.Commit("regular commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- var stdout bytes.Buffer
- err = runExplainCommit(context.Background(), &stdout, &stdout, commitHash.String(), false, false, false, false, false, false, false)
- if err != nil {
- t.Fatalf("runExplainCommit() should not error for non-Trace commits, got: %v", err)
- }
-
- output := stdout.String()
-
- // Should show message indicating no Trace checkpoint (new failure-block shape)
- if !strings.Contains(output, "✗ No associated Trace checkpoint") {
- t.Errorf("expected styled failure block on output, got: %s", output)
- }
- if !strings.Contains(output, " reason") {
- t.Errorf("expected reason row, got: %s", output)
- }
- // Should mention the commit hash
- if !strings.Contains(output, commitHash.String()[:7]) {
- t.Errorf("expected output to contain short commit hash, got: %s", output)
- }
-}
-
-func TestExplainCommit_WithMetadataTrailerButNoCheckpoint(t *testing.T) {
- // Test that commits with Trace-Metadata trailer (but no Trace-Checkpoint)
- // now show "no checkpoint" message (new behavior)
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create session metadata directory first
- sessionID := "2025-12-09-test-session-xyz789"
- sessionDir := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
- if err := os.MkdirAll(sessionDir, 0o750); err != nil {
- t.Fatalf("failed to create session dir: %v", err)
- }
-
- // Create prompt file
- promptContent := "Add new feature"
- if err := os.WriteFile(filepath.Join(sessionDir, paths.PromptFileName), []byte(promptContent), 0o644); err != nil {
- t.Fatalf("failed to create prompt file: %v", err)
- }
-
- // Create a commit with Trace-Metadata trailer (but NO Trace-Checkpoint)
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("feature content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
-
- // Commit with Trace-Metadata trailer (no Trace-Checkpoint)
- metadataDir := ".trace/metadata/" + sessionID
- commitMessage := trailers.FormatMetadata("Add new feature", metadataDir)
- commitHash, err := w.Commit(commitMessage, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- var stdout bytes.Buffer
- err = runExplainCommit(context.Background(), &stdout, &stdout, commitHash.String(), false, false, false, false, false, false, false)
- if err != nil {
- t.Fatalf("runExplainCommit() error = %v", err)
- }
-
- output := stdout.String()
-
- // New behavior: should show "no checkpoint" failure block since there's no Trace-Checkpoint trailer
- if !strings.Contains(output, "✗ No associated Trace checkpoint") {
- t.Errorf("expected styled failure block, got: %s", output)
- }
- if !strings.Contains(output, " reason") {
- t.Errorf("expected reason row, got: %s", output)
- }
- // Should mention the commit hash
- if !strings.Contains(output, commitHash.String()[:7]) {
- t.Errorf("expected output to contain short commit hash, got: %s", output)
- }
-}
-
-func TestExplainDefault_ShowsBranchView(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- // Create initial commit so HEAD exists (required for branch view)
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- var stdout bytes.Buffer
- err = runExplainDefault(context.Background(), &stdout, true) // noPager=true for test
- // Should NOT error - should show branch view
- if err != nil {
- t.Errorf("expected no error, got: %v", err)
- }
-
- output := stdout.String()
- // Should show branch header (new metadata-row shape: "branch ")
- if !strings.Contains(output, "branch ") {
- t.Errorf("expected 'branch' row in output, got: %s", output)
- }
- // Should show checkpoints count (likely 0)
- if !strings.Contains(output, "checkpoints") {
- t.Errorf("expected 'checkpoints' row in output, got: %s", output)
- }
-}
-
-func TestExplainDefault_NoCheckpoints_ShowsHelpfulMessage(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- // Create initial commit so HEAD exists (required for branch view)
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create .trace directory but no checkpoints
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- var stdout bytes.Buffer
- err = runExplainDefault(context.Background(), &stdout, true) // noPager=true for test
- // Should NOT error
- if err != nil {
- t.Errorf("expected no error, got: %v", err)
- }
-
- output := stdout.String()
- // Should show checkpoints count as 0 (new metadata-row shape)
- if !strings.Contains(output, "checkpoints 0") {
- t.Errorf("expected 'checkpoints 0' in output, got: %s", output)
- }
- // Should show helpful message about checkpoints appearing after saves
- if !strings.Contains(output, "Checkpoints will appear") || !strings.Contains(output, "agent session") {
- t.Errorf("expected helpful message about checkpoints, got: %s", output)
- }
-}
-
-func TestExplainBothFlagsError(t *testing.T) {
- // Test that providing both --session and --commit returns an error
- var stdout, stderr bytes.Buffer
- err := runExplain(context.Background(), &stdout, &stderr, "session-id", "commit-sha", "", "", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error when both flags provided, got nil")
- }
- // Case-insensitive check for "cannot specify multiple"
- errLower := strings.ToLower(err.Error())
- if !strings.Contains(errLower, "cannot specify multiple") {
- t.Errorf("expected 'cannot specify multiple' in error, got: %v", err)
- }
-}
-
-func TestFormatSessionInfo(t *testing.T) {
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-09-test-session-abc",
- Description: "Test description",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{
- {
- CheckpointID: "abc1234567890",
- Message: "First checkpoint",
- Timestamp: now.Add(-time.Hour),
- },
- {
- CheckpointID: "def0987654321",
- Message: "Second checkpoint",
- Timestamp: now,
- },
- },
- }
-
- // Create checkpoint details matching the session checkpoints
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "abc1234",
- Timestamp: now.Add(-time.Hour),
- Message: "First checkpoint",
- Interactions: []interaction{{
- Prompt: "Fix the bug",
- Responses: []string{"Fixed the bug in auth module"},
- Files: []string{"auth.go"},
- }},
- Files: []string{"auth.go"},
- },
- {
- Index: 2,
- ShortID: "def0987",
- Timestamp: now,
- Message: "Second checkpoint",
- Interactions: []interaction{{
- Prompt: "Add tests",
- Responses: []string{"Added unit tests"},
- Files: []string{"auth_test.go"},
- }},
- Files: []string{"auth_test.go"},
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Verify output contains expected sections
- if !strings.Contains(output, "Session:") {
- t.Error("expected output to contain 'Session:'")
- }
- if !strings.Contains(output, session.ID) {
- t.Error("expected output to contain session ID")
- }
- if !strings.Contains(output, "Strategy:") {
- t.Error("expected output to contain 'Strategy:'")
- }
- if !strings.Contains(output, "manual-commit") {
- t.Error("expected output to contain strategy name")
- }
- if !strings.Contains(output, "Checkpoints: 2") {
- t.Error("expected output to contain 'Checkpoints: 2'")
- }
- // Check checkpoint details
- if !strings.Contains(output, "Checkpoint 1") {
- t.Error("expected output to contain 'Checkpoint 1'")
- }
- if !strings.Contains(output, "## Prompt") {
- t.Error("expected output to contain '## Prompt'")
- }
- if !strings.Contains(output, "## Responses") {
- t.Error("expected output to contain '## Responses'")
- }
- if !strings.Contains(output, "Files Modified") {
- t.Error("expected output to contain 'Files Modified'")
- }
-}
-
-func TestFormatSessionInfo_WithSourceRef(t *testing.T) {
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-09-test-session-abc",
- Description: "Test description",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{
- {
- CheckpointID: "abc1234567890",
- Message: "First checkpoint",
- Timestamp: now,
- },
- },
- }
-
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "abc1234",
- Timestamp: now,
- Message: "First checkpoint",
- },
- }
-
- // Test with source ref provided
- sourceRef := "trace/metadata@abc123def456"
- output := formatSessionInfo(session, sourceRef, checkpointDetails)
-
- // Verify source ref is displayed
- if !strings.Contains(output, "Source Ref:") {
- t.Error("expected output to contain 'Source Ref:'")
- }
- if !strings.Contains(output, sourceRef) {
- t.Errorf("expected output to contain source ref %q, got:\n%s", sourceRef, output)
- }
-}
-
-// TestManualCommitStrategyCallable verifies that the strategy's methods are callable
-func TestManualCommitStrategyCallable(t *testing.T) {
- s := strategy.NewManualCommitStrategy()
-
- // GetAdditionalSessions should exist and be callable
- _, err := s.GetAdditionalSessions(context.Background())
- if err != nil {
- t.Logf("GetAdditionalSessions returned error: %v", err)
- }
-}
-
-func TestFormatSessionInfo_CheckpointNumberingReversed(t *testing.T) {
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-09-test-session",
- Strategy: "manual-commit",
- StartTime: now.Add(-2 * time.Hour),
- Checkpoints: []strategy.Checkpoint{}, // Not used for format test
- }
-
- // Simulate checkpoints coming in newest-first order from ListSessions
- // but numbered with oldest=1, newest=N
- checkpointDetails := []checkpointDetail{
- {
- Index: 3, // Newest checkpoint should have highest number
- ShortID: "ccc3333",
- Timestamp: now,
- Message: "Third (newest) checkpoint",
- Interactions: []interaction{{
- Prompt: "Latest change",
- Responses: []string{},
- }},
- },
- {
- Index: 2,
- ShortID: "bbb2222",
- Timestamp: now.Add(-time.Hour),
- Message: "Second checkpoint",
- Interactions: []interaction{{
- Prompt: "Middle change",
- Responses: []string{},
- }},
- },
- {
- Index: 1, // Oldest checkpoint should be #1
- ShortID: "aaa1111",
- Timestamp: now.Add(-2 * time.Hour),
- Message: "First (oldest) checkpoint",
- Interactions: []interaction{{
- Prompt: "Initial change",
- Responses: []string{},
- }},
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Verify checkpoint ordering in output
- // Checkpoint 3 should appear before Checkpoint 2 which should appear before Checkpoint 1
- idx3 := strings.Index(output, "Checkpoint 3")
- idx2 := strings.Index(output, "Checkpoint 2")
- idx1 := strings.Index(output, "Checkpoint 1")
-
- if idx3 == -1 || idx2 == -1 || idx1 == -1 {
- t.Fatalf("expected all checkpoints to be in output, got:\n%s", output)
- }
-
- // In the output, they should appear in the order they're in the slice (newest first)
- if idx3 > idx2 || idx2 > idx1 {
- t.Errorf("expected checkpoints to appear in order 3, 2, 1 in output (newest first), got positions: 3=%d, 2=%d, 1=%d", idx3, idx2, idx1)
- }
-
- // Verify the dates appear correctly
- if !strings.Contains(output, "Latest change") {
- t.Error("expected output to contain 'Latest change' prompt")
- }
- if !strings.Contains(output, "Initial change") {
- t.Error("expected output to contain 'Initial change' prompt")
- }
-}
-
-func TestFormatSessionInfo_EmptyCheckpoints(t *testing.T) {
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-09-empty-session",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- output := formatSessionInfo(session, "", nil)
-
- if !strings.Contains(output, "Checkpoints: 0") {
- t.Errorf("expected output to contain 'Checkpoints: 0', got:\n%s", output)
- }
-}
-
-func TestFormatSessionInfo_CheckpointWithTaskMarker(t *testing.T) {
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-09-task-session",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "abc1234",
- Timestamp: now,
- IsTaskCheckpoint: true,
- Message: "Task checkpoint",
- Interactions: []interaction{{
- Prompt: "Run tests",
- Responses: []string{},
- }},
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- if !strings.Contains(output, "[Task]") {
- t.Errorf("expected output to contain '[Task]' marker, got:\n%s", output)
- }
-}
-
-func TestFormatSessionInfo_CheckpointWithDate(t *testing.T) {
- // Test that checkpoint headers include the full date
- timestamp := time.Date(2025, 12, 10, 14, 35, 0, 0, time.UTC)
- session := &strategy.Session{
- ID: "2025-12-10-dated-session",
- Strategy: "manual-commit",
- StartTime: timestamp,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "abc1234",
- Timestamp: timestamp,
- Message: "Test checkpoint",
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Should contain "2025-12-10 14:35" in the checkpoint header
- if !strings.Contains(output, "2025-12-10 14:35") {
- t.Errorf("expected output to contain date '2025-12-10 14:35', got:\n%s", output)
- }
-}
-
-func TestFormatSessionInfo_ShowsMessageWhenNoInteractions(t *testing.T) {
- // Test that checkpoints without transcript content show the commit message
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-12-incremental-session",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- // Checkpoint with message but no interactions (like incremental checkpoints)
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "abc1234",
- Timestamp: now,
- IsTaskCheckpoint: true,
- Message: "Starting 'dev' agent: Implement feature X (toolu_01ABC)",
- Interactions: []interaction{}, // Empty - no transcript available
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Should show the commit message when there are no interactions
- if !strings.Contains(output, "Starting 'dev' agent: Implement feature X (toolu_01ABC)") {
- t.Errorf("expected output to contain commit message when no interactions, got:\n%s", output)
- }
-
- // Should NOT show "## Prompt" or "## Responses" sections since there are no interactions
- if strings.Contains(output, "## Prompt") {
- t.Errorf("expected output to NOT contain '## Prompt' when no interactions, got:\n%s", output)
- }
- if strings.Contains(output, "## Responses") {
- t.Errorf("expected output to NOT contain '## Responses' when no interactions, got:\n%s", output)
- }
-}
-
-func TestFormatSessionInfo_ShowsMessageAndFilesWhenNoInteractions(t *testing.T) {
- // Test that checkpoints without transcript but with files show both message and files
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-12-incremental-with-files",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "def5678",
- Timestamp: now,
- IsTaskCheckpoint: true,
- Message: "Running tests for API endpoint (toolu_02DEF)",
- Interactions: []interaction{}, // Empty - no transcript
- Files: []string{"api/endpoint.go", "api/endpoint_test.go"},
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Should show the commit message
- if !strings.Contains(output, "Running tests for API endpoint (toolu_02DEF)") {
- t.Errorf("expected output to contain commit message, got:\n%s", output)
- }
-
- // Should also show the files
- if !strings.Contains(output, "Files Modified") {
- t.Errorf("expected output to contain 'Files Modified', got:\n%s", output)
- }
- if !strings.Contains(output, "api/endpoint.go") {
- t.Errorf("expected output to contain modified file, got:\n%s", output)
- }
-}
-
-func TestFormatSessionInfo_DoesNotShowMessageWhenHasInteractions(t *testing.T) {
- // Test that checkpoints WITH interactions don't show the message separately
- // (the interactions already contain the content)
- now := time.Now()
- session := &strategy.Session{
- ID: "2025-12-12-full-checkpoint",
- Strategy: "manual-commit",
- StartTime: now,
- Checkpoints: []strategy.Checkpoint{},
- }
-
- checkpointDetails := []checkpointDetail{
- {
- Index: 1,
- ShortID: "ghi9012",
- Timestamp: now,
- IsTaskCheckpoint: true,
- Message: "Completed 'dev' agent: Implement feature (toolu_03GHI)",
- Interactions: []interaction{
- {
- Prompt: "Implement the feature",
- Responses: []string{"I've implemented the feature by..."},
- Files: []string{"feature.go"},
- },
- },
- },
- }
-
- output := formatSessionInfo(session, "", checkpointDetails)
-
- // Should show the interaction content
- if !strings.Contains(output, "Implement the feature") {
- t.Errorf("expected output to contain prompt, got:\n%s", output)
- }
- if !strings.Contains(output, "I've implemented the feature by") {
- t.Errorf("expected output to contain response, got:\n%s", output)
- }
-
- // The message should NOT appear as a separate line (it's redundant when we have interactions)
- // The output should contain ## Prompt and ## Responses for the interaction
- if !strings.Contains(output, "## Prompt") {
- t.Errorf("expected output to contain '## Prompt' when has interactions, got:\n%s", output)
- }
-}
-
-func TestExplainCmd_HasCheckpointFlag(t *testing.T) {
- cmd := newExplainCmd()
-
- flag := cmd.Flags().Lookup("checkpoint")
- if flag == nil {
- t.Error("expected --checkpoint flag to exist")
- }
-}
-
-func TestExplainCmd_HasShortFlag(t *testing.T) {
- cmd := newExplainCmd()
-
- flag := cmd.Flags().Lookup("short")
- if flag == nil {
- t.Fatal("expected --short flag to exist")
- return // unreachable but satisfies staticcheck
- }
-
- // Should have -s shorthand
- if flag.Shorthand != "s" {
- t.Errorf("expected -s shorthand, got %q", flag.Shorthand)
- }
-}
-
-func TestExplainCmd_HasFullFlag(t *testing.T) {
- cmd := newExplainCmd()
-
- flag := cmd.Flags().Lookup("full")
- if flag == nil {
- t.Error("expected --full flag to exist")
- }
-}
-
-func TestExplainCmd_HasRawTranscriptFlag(t *testing.T) {
- cmd := newExplainCmd()
-
- flag := cmd.Flags().Lookup("raw-transcript")
- if flag == nil {
- t.Error("expected --raw-transcript flag to exist")
- }
-}
-
-func TestRunExplain_MutualExclusivityError(t *testing.T) {
- var buf, errBuf bytes.Buffer
-
- // Providing both --session and --checkpoint should error
- err := runExplain(context.Background(), &buf, &errBuf, "session-id", "", "checkpoint-id", "", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error when multiple flags provided")
- }
- if !strings.Contains(err.Error(), "cannot specify multiple") {
- t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
- }
-}
-
-func TestRunExplainCheckpoint_NotFound(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo with an initial commit (required for checkpoint lookup)
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "nonexistent123", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error for nonexistent checkpoint")
- }
- if !strings.Contains(err.Error(), "checkpoint not found") {
- t.Errorf("expected 'checkpoint not found' error, got: %v", err)
- }
-}
-
-func TestRunExplainCheckpoint_V2OnlyCheckpoint(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- if err != nil {
- t.Fatalf("failed to open git repo: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := wt.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
- t.Fatalf("failed to create .trace directory: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("777777777777")
- ctx := context.Background()
-
- if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-v2",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello from v2"}]}}` + "\n")),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }); err != nil {
- t.Fatalf("failed to write v2 checkpoint: %v", err)
- }
-
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "777777", false, false, false, false, false, false, false)
- if err != nil {
- t.Fatalf("expected success for v2-only checkpoint, got error: %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "● Checkpoint 777777777777") {
- t.Fatalf("expected checkpoint header in output, got: %s", output)
- }
- if !strings.Contains(output, "session-v2") {
- t.Fatalf("expected v2 session ID in output, got: %s", output)
- }
-}
-
-func TestRunExplainCheckpoint_V2OnlyRawTranscript(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- if err != nil {
- t.Fatalf("failed to open git repo: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := wt.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
- t.Fatalf("failed to create .trace directory: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("888888888888")
- ctx := context.Background()
-
- if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-v2",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw from v2"}]}}` + "\n")),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }); err != nil {
- t.Fatalf("failed to write v2 checkpoint: %v", err)
- }
-
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "888888", false, false, false, true, false, false, false)
- if err != nil {
- t.Fatalf("expected success for v2-only raw transcript, got error: %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "raw from v2") {
- t.Fatalf("expected v2 raw transcript in output, got: %s", output)
- }
-}
-
-func TestRunExplainCheckpoint_V2CheckpointRemoteFallbackResolvesRawTranscript(t *testing.T) {
- ctx := context.Background()
-
- emptyConfig := filepath.Join(t.TempDir(), "empty-git-config")
- require.NoError(t, os.WriteFile(emptyConfig, []byte(""), 0o644))
- t.Setenv("GIT_CONFIG_GLOBAL", emptyConfig)
- t.Setenv("GIT_CONFIG_SYSTEM", emptyConfig)
-
- checkpointDir := t.TempDir()
- testutil.InitRepo(t, checkpointDir)
- testutil.WriteFile(t, checkpointDir, "checkpoint.txt", "checkpoint")
- testutil.GitAdd(t, checkpointDir, "checkpoint.txt")
- testutil.GitCommit(t, checkpointDir, "checkpoint init")
-
- checkpointRepo, err := git.PlainOpen(checkpointDir)
- require.NoError(t, err)
- t.Cleanup(func() {
- // Close the underlying storage to release file descriptors before
- // t.TempDir() attempts to remove the directory.
- if storer, ok := checkpointRepo.Storer.(interface{ Close() error }); ok {
- _ = storer.Close()
- }
- })
-
- cpID := id.MustCheckpointID("121212121212")
- rawTranscript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw from checkpoint_remote"}]}}` + "\n")
- checkpointStore := checkpoint.NewV2GitStore(checkpointRepo, "origin")
- require.NoError(t, checkpointStore.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-checkpoint-remote",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(rawTranscript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- localDir := t.TempDir()
- t.Chdir(localDir)
-
- testutil.InitRepo(t, localDir)
- testutil.WriteFile(t, localDir, "local.txt", "local")
- testutil.GitAdd(t, localDir, "local.txt")
- testutil.GitCommit(t, localDir, "local init")
-
- cmd := exec.CommandContext(ctx, "git", "remote", "add", "origin", "git@github.com:user/source.git")
- cmd.Dir = localDir
- cmd.Env = testutil.GitIsolatedEnv()
- require.NoError(t, cmd.Run())
-
- sshScript := filepath.Join(t.TempDir(), "fake-ssh")
- require.NoError(t, os.WriteFile(sshScript, []byte(`#!/bin/bash
-set -euo pipefail
-cmd="${@: -1}"
-case "$cmd" in
- *"user/source.git"*)
- echo "origin intentionally unavailable" >&2
- exit 1
- ;;
- *"org/checkpoints.git"*) repo="$CHECKPOINT_REPO" ;;
- *)
- echo "unexpected ssh command: $cmd" >&2
- exit 1
- ;;
-esac
-exec git-upload-pack "$repo"
-`), 0o755))
- t.Setenv("GIT_SSH", sshScript)
- t.Setenv("GIT_SSH_COMMAND", sshScript) // GIT_SSH_COMMAND takes priority over GIT_SSH on systems where it's set globally.
- t.Setenv("CHECKPOINT_REPO", checkpointDir)
-
- require.NoError(t, os.MkdirAll(filepath.Join(localDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(localDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true, "checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`),
- 0o644,
- ))
-
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(ctx, &buf, &errBuf, "121212", false, false, false, true, false, false, false)
- require.NoError(t, err)
- require.Contains(t, buf.String(), "raw from checkpoint_remote")
-}
-
-func TestRunExplainCheckpoint_V2UsesCompactTranscriptForIntent(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- if err != nil {
- t.Fatalf("failed to open git repo: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := wt.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil {
- t.Fatalf("failed to create .trace directory: %v", err)
- }
- if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`), 0o644); err != nil {
- t.Fatalf("failed to write settings: %v", err)
- }
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("999999999999")
- ctx := context.Background()
-
- compactTranscript := []byte(
- `{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"user","ts":"2026-01-01T00:00:00Z","content":[{"text":"compact prompt text"}]}` + "\n" +
- `{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"assistant","ts":"2026-01-01T00:00:01Z","id":"m1","content":[{"type":"text","text":"assistant reply"}]}` + "\n",
- )
-
- if err := v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-v2",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw prompt text"}]}}` + "\n")),
- CompactTranscript: compactTranscript,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- CheckpointTranscriptStart: 0,
- }); err != nil {
- t.Fatalf("failed to write v2 checkpoint: %v", err)
- }
-
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(context.Background(), &buf, &errBuf, "999999", false, false, false, false, false, false, false)
- if err != nil {
- t.Fatalf("expected success for v2 checkpoint, got error: %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "## Intent") {
- t.Fatalf("expected '## Intent' heading in no-color output, got: %s", output)
- }
- if !strings.Contains(output, "compact prompt text") {
- t.Fatalf("expected compact transcript to drive intent extraction, got: %s", output)
- }
-}
-
-func TestRunExplainCheckpoint_V2PreferredGenerateWritesBothStores(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- v1Store := checkpoint.NewGitStore(repo)
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("aabbccddeeff")
- ctx := context.Background()
-
- transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"generate test"}]}}` + "\n" +
- `{"type":"assistant","message":{"content":"done"}}` + "\n")
-
- // Dual-write: checkpoint exists in both v1 and v2.
- require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // generate=true, force=true — should succeed by writing to both v1 and v2 stores.
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(ctx, &buf, &errBuf, "aabbcc", false, false, false, false, true, true, false)
- // Generation requires an AI summarizer which isn't available in unit tests,
- // but the important thing is we don't get the old "only v1 checkpoints supported" error.
- if err != nil && strings.Contains(err.Error(), "summary updates are currently supported only for v1 checkpoints") {
- t.Fatalf("should not reject v2-resolved checkpoints for generation when v1 has the data: %v", err)
- }
-}
-
-func TestRunExplainCheckpoint_V2OnlyGenerateSucceedsViaV2Store(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("f1f2f3f4f5f6")
- ctx := context.Background()
-
- transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"v2-only generate"}]}}` + "\n" +
- `{"type":"assistant","message":{"content":"done"}}` + "\n")
-
- // Write to v2 only — no v1 checkpoint exists.
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-v2-only",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // generate=true, force=true — should not fail with "failed to save summary"
- // because v2 store can persist even when v1 doesn't have the checkpoint.
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(ctx, &buf, &errBuf, "f1f2f3", false, false, false, false, true, true, false)
- if err != nil {
- errMsg := err.Error()
- if strings.Contains(errMsg, "claude") || strings.Contains(errMsg, "executable file not found") {
- t.Skipf("skipping: summarizer unavailable in CI: %v", err)
- }
- require.NotContains(t, errMsg, "failed to save summary",
- "v2-only checkpoint should persist summary via v2 store")
- }
-}
-
-func TestRunExplainCheckpoint_V2FallsBackToFullWhenCompactMissing(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("e1e2e3e4e5e6")
- ctx := context.Background()
-
- rawTranscript := []byte(
- `{"type":"user","message":{"content":[{"type":"text","text":"raw fallback prompt"}]}}` + "\n" +
- `{"type":"assistant","message":{"content":"raw reply"}}` + "\n",
- )
-
- // Write checkpoint with raw transcript but NO compact transcript.
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-no-compact",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(rawTranscript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // Default explain (not --full) should fall back to /full/current transcript
- // when compact transcript is missing on /main.
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(ctx, &buf, &errBuf, "e1e2e3", false, false, false, false, false, false, false)
- require.NoError(t, err)
-
- output := buf.String()
- require.Contains(t, output, "raw fallback prompt",
- "should use raw transcript from /full/current when compact is missing")
-}
-
-func TestRunExplainCheckpoint_V2CompactTranscriptNotUsedForGenerate(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- v1Store := checkpoint.NewGitStore(repo)
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- cpID := id.MustCheckpointID("c0c1c2c3c4c5")
- ctx := context.Background()
-
- rawTranscript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"raw prompt for summarizer"}]}}` + "\n" +
- `{"type":"assistant","message":{"content":"raw reply"}}` + "\n")
- compactTranscript := []byte(`{"v":1,"agent":"claude-code","cli_version":"0.5.1","type":"user","content":[{"text":"compact prompt"}]}` + "\n")
-
- // Dual-write with compact transcript.
- require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-compact",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(rawTranscript),
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-compact",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(rawTranscript),
- CompactTranscript: compactTranscript,
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // generate=true — should NOT fail with "no transcript content" which would
- // indicate the compact transcript was incorrectly fed to the summarizer.
- var buf, errBuf bytes.Buffer
- err = runExplainCheckpoint(ctx, &buf, &errBuf, "c0c1c2", false, false, false, false, true, true, false)
- if err != nil && strings.Contains(err.Error(), "no transcript content for this checkpoint") {
- t.Fatalf("compact transcript should not be used for --generate; raw transcript should be used instead: %v", err)
- }
-}
-
-func TestListCommittedForExplain_MergesV1AndV2(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "f.txt"), []byte("x"), 0o644))
- _, err = wt.Add("f.txt")
- require.NoError(t, err)
- _, err = wt.Commit("init", &git.CommitOptions{
- Author: &object.Signature{Name: "T", Email: "t@t.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- v1Store := checkpoint.NewGitStore(repo)
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")
-
- // Write a v1-only checkpoint (pre-v2 era).
- v1OnlyID := id.MustCheckpointID("aaa111222333")
- require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: v1OnlyID,
- SessionID: "session-v1-only",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "T",
- AuthorEmail: "t@t.com",
- }))
-
- // Write a dual-write checkpoint (exists in both v1 and v2).
- dualID := id.MustCheckpointID("bbb444555666")
- require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: dualID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "T",
- AuthorEmail: "t@t.com",
- }))
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: dualID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "T",
- AuthorEmail: "t@t.com",
- }))
-
- // With v2 preferred: should return both the dual-write AND the v1-only checkpoint.
- results, err := listCommittedForExplain(ctx, v1Store, v2Store, true)
- require.NoError(t, err)
-
- foundIDs := make(map[id.CheckpointID]bool)
- for _, r := range results {
- foundIDs[r.CheckpointID] = true
- }
- require.True(t, foundIDs[v1OnlyID], "v1-only checkpoint should be visible when v2 is preferred")
- require.True(t, foundIDs[dualID], "dual-write checkpoint should be visible")
-
- // No duplicates: dual checkpoint should appear exactly once.
- dualCount := 0
- for _, r := range results {
- if r.CheckpointID == dualID {
- dualCount++
- }
- }
- require.Equal(t, 1, dualCount, "dual-write checkpoint should not be duplicated")
-}
-
-func TestListCommittedForExplain_V2Disabled_ReturnsV1Only(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "f.txt"), []byte("x"), 0o644))
- _, err = wt.Add("f.txt")
- require.NoError(t, err)
- _, err = wt.Commit("init", &git.CommitOptions{
- Author: &object.Signature{Name: "T", Email: "t@t.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- v1Store := checkpoint.NewGitStore(repo)
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- ctx := context.Background()
-
- transcript := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")
-
- v1ID := id.MustCheckpointID("ccc777888999")
- require.NoError(t, v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: v1ID,
- SessionID: "session-v1",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "T",
- AuthorEmail: "t@t.com",
- }))
-
- // v2 also has a checkpoint, but v2 is disabled — should only see v1.
- v2ID := id.MustCheckpointID("ddd000111222")
- require.NoError(t, v2Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: v2ID,
- SessionID: "session-v2",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- AuthorName: "T",
- AuthorEmail: "t@t.com",
- }))
-
- results, err := listCommittedForExplain(ctx, v1Store, v2Store, false)
- require.NoError(t, err)
-
- foundIDs := make(map[id.CheckpointID]bool)
- for _, r := range results {
- foundIDs[r.CheckpointID] = true
- }
- require.True(t, foundIDs[v1ID], "v1 checkpoint should be returned")
- require.False(t, foundIDs[v2ID], "v2-only checkpoint should NOT appear when v2 is disabled")
-}
-
-func TestFormatCheckpointOutput_Short(t *testing.T) {
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- CheckpointsCount: 3,
- FilesTouched: []string{"main.go", "util.go"},
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-21-test-session",
- CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go", "util.go"},
- CheckpointsCount: 3,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- },
- Prompts: "Add a new feature",
- }
-
- // Default mode: empty commit message (not shown anyway in default mode)
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
-
- // Should show checkpoint ID
- if !strings.Contains(output, "abc123def456") {
- t.Error("expected checkpoint ID in output")
- }
- // Should show session ID
- if !strings.Contains(output, "2026-01-21-test-session") {
- t.Error("expected session ID in output")
- }
- // Should show timestamp
- if !strings.Contains(output, "2026-01-21") {
- t.Error("expected timestamp in output")
- }
- // Should show token usage (10000 + 5000 = 15000), formatted compactly.
- if !strings.Contains(output, " tokens 15k") {
- t.Error("expected token count in output")
- }
- // Should show Intent heading (markdown body)
- if !strings.Contains(output, "## Intent") {
- t.Errorf("expected '## Intent' heading in no-color output, got:\n%s", output)
- }
- // Should show Summary heading with --generate hint affordance
- if !strings.Contains(output, "## Summary") {
- t.Errorf("expected '## Summary' heading in no-color output, got:\n%s", output)
- }
- if !strings.Contains(output, "trace explain --generate") {
- t.Errorf("expected --generate hint in summary affordance, got:\n%s", output)
- }
- // Should NOT show full file list in default mode
- if strings.Contains(output, "main.go") {
- t.Error("default output should not show file list (use --full)")
- }
-}
-
-func TestFormatCheckpointOutput_Verbose(t *testing.T) {
- // Transcript with user prompts that match what we expect to see
- transcriptContent := []byte(`{"type":"user","uuid":"u1","message":{"content":"Add a new feature"}}
-{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"I'll add the feature"}]}}
-{"type":"user","uuid":"u2","message":{"content":"Fix the bug"}}
-{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Fixed it"}]}}
-{"type":"user","uuid":"u3","message":{"content":"Refactor the code"}}
-`)
-
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- CheckpointsCount: 3,
- FilesTouched: []string{"main.go", "util.go", "config.yaml"},
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-21-test-session",
- CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go", "util.go", "config.yaml"},
- CheckpointsCount: 3,
- CheckpointTranscriptStart: 0, // All content is this checkpoint's
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- },
- Prompts: "Add a new feature\nFix the bug\nRefactor the code",
- Transcript: transcriptContent,
- }
-
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Should show checkpoint ID (like default)
- if !strings.Contains(output, "abc123def456") {
- t.Error("expected checkpoint ID in output")
- }
- // Should show session ID (like default)
- if !strings.Contains(output, "2026-01-21-test-session") {
- t.Error("expected session ID in output")
- }
- // Verbose should show files (with backticks in markdown list items)
- if !strings.Contains(output, "`main.go`") {
- t.Error("verbose output should show files")
- }
- if !strings.Contains(output, "`util.go`") {
- t.Error("verbose output should show all files")
- }
- if !strings.Contains(output, "`config.yaml`") {
- t.Error("verbose output should show all files")
- }
- // Should show "## Files (N)" markdown heading
- if !strings.Contains(output, "## Files (3)") {
- t.Errorf("verbose output should have '## Files (3)' heading, got:\n%s", output)
- }
- // Verbose should show scoped transcript section
- if !strings.Contains(output, "Transcript (checkpoint scope)") {
- t.Error("verbose output should have Transcript (checkpoint scope) section")
- }
- if !strings.Contains(output, "Add a new feature") {
- t.Error("verbose output should show prompts")
- }
-}
-
-func TestFormatCheckpointOutput_Verbose_NoCommitMessage(t *testing.T) {
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- CheckpointsCount: 1,
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-21-test-session",
- CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointsCount: 1,
- },
- Prompts: "Add a feature",
- }
-
- // When commit message is empty, should not show Commit section
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- if strings.Contains(output, " commits") {
- t.Error("verbose output should not show Commits section when nil (not searched)")
- }
-}
-
-func TestFormatCheckpointOutput_Full(t *testing.T) {
- // Use proper transcript format that matches actual Claude transcripts
- transcriptData := `{"type":"user","message":{"content":"Add a new feature"}}
-{"type":"assistant","message":{"content":[{"type":"text","text":"I'll add that feature for you."}]}}`
-
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- CheckpointsCount: 3,
- FilesTouched: []string{"main.go", "util.go"},
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-21-test-session",
- CreatedAt: time.Date(2026, 1, 21, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go", "util.go"},
- CheckpointsCount: 3,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 10000,
- OutputTokens: 5000,
- },
- },
- Prompts: "Add a new feature",
- Transcript: []byte(transcriptData),
- }
-
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, true, &bytes.Buffer{})
-
- // Should show checkpoint ID (like default)
- if !strings.Contains(output, "abc123def456") {
- t.Error("expected checkpoint ID in output")
- }
- // Full should also include verbose sections (## Files heading)
- if !strings.Contains(output, "## Files (2)") {
- t.Errorf("full output should include '## Files (2)' heading, got:\n%s", output)
- }
- // Full shows full session transcript (not scoped)
- if !strings.Contains(output, "Transcript (full session)") {
- t.Error("full output should have Transcript (full session) section")
- }
- // Should contain actual transcript content (parsed format)
- if !strings.Contains(output, "Add a new feature") {
- t.Error("full output should show transcript content")
- }
- if !strings.Contains(output, "[Assistant]") {
- t.Error("full output should show assistant messages in parsed transcript")
- }
-}
-
-func TestFormatCheckpointOutput_WithSummary(t *testing.T) {
- cpID := id.MustCheckpointID("abc123456789")
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: cpID,
- FilesTouched: []string{"file1.go", "file2.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: cpID,
- SessionID: "2026-01-22-test-session",
- CreatedAt: time.Date(2026, 1, 22, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"file1.go", "file2.go"},
- Summary: &checkpoint.Summary{
- Intent: "Implement user authentication",
- Outcome: "Added login and logout functionality",
- Learnings: checkpoint.LearningsSummary{
- Repo: []string{"Uses JWT for auth tokens"},
- Code: []checkpoint.CodeLearning{{Path: "auth.go", Line: 42, Finding: "Token validation happens here"}},
- Workflow: []string{"Always run tests after auth changes"},
- },
- Friction: []string{"Had to refactor session handling"},
- OpenItems: []string{"Add password reset flow"},
- },
- },
- Prompts: "Add user authentication",
- }
-
- // Test default output (non-verbose) with summary
- output := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
-
- // Should show AI-generated intent and outcome as markdown.
- if !strings.Contains(output, "## Intent\n\nImplement user authentication") {
- t.Errorf("expected AI intent in output, got:\n%s", output)
- }
- if !strings.Contains(output, "## Outcome\n\nAdded login and logout functionality") {
- t.Errorf("expected AI outcome in output, got:\n%s", output)
- }
- // Summary markdown includes all generated summary sections.
- if !strings.Contains(output, "## Learnings") {
- t.Errorf("summary output should show learnings, got:\n%s", output)
- }
-
- // Test verbose output with summary
- verboseOutput := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Verbose should show learnings sections
- if !strings.Contains(verboseOutput, "## Learnings") {
- t.Errorf("verbose output should show learnings, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "### Repository") {
- t.Errorf("verbose output should show repository learnings, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "Uses JWT for auth tokens") {
- t.Errorf("verbose output should show repo learning content, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "### Code") {
- t.Errorf("verbose output should show code learnings, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "`auth.go:42`") {
- t.Errorf("verbose output should show code learning with line number, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "### Workflow") {
- t.Errorf("verbose output should show workflow learnings, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "## Friction") {
- t.Errorf("verbose output should show friction, got:\n%s", verboseOutput)
- }
- if !strings.Contains(verboseOutput, "## Open Items") {
- t.Errorf("verbose output should show open items, got:\n%s", verboseOutput)
- }
-}
-
-func TestFormatCheckpointOutput_SummaryStartsAfterTightHeaderRule(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("abc123456789")
- summary := &checkpoint.CheckpointSummary{CheckpointID: cpID}
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: cpID,
- SessionID: "2026-01-22-test-session",
- CreatedAt: time.Date(2026, 1, 22, 10, 30, 0, 0, time.UTC),
- Summary: &checkpoint.Summary{
- Intent: "Implement user authentication",
- Outcome: "Added login and logout functionality",
- },
- },
- }
-
- output := formatCheckpointOutput(summary, content, cpID, nil, checkpoint.Author{}, false, false, &bytes.Buffer{})
- rule := strings.Repeat("─", 60)
- want := " created 2026-01-22 10:30:00\n" + rule + "\n## Intent"
-
- if !strings.Contains(output, want) {
- t.Fatalf("expected summary to start immediately after header rule, got:\n%s", output)
- }
-}
-
-func TestBuildSummaryMarkdown_FullSummary(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "Rotate session tokens on logout",
- Outcome: "Logout now mints a new token",
- Learnings: checkpoint.LearningsSummary{
- Repo: []string{"Auth lives behind the auth_v2 gate"},
- Code: []checkpoint.CodeLearning{
- {Path: "auth/session.go", Line: 42, Finding: "Rotate before cookie clear"},
- },
- Workflow: []string{"Manual curl confirmed the path"},
- },
- Friction: []string{"go-git v5 reset deleted .trace"},
- OpenItems: []string{"Backfill rotation for legacy cookies"},
- }
-
- got := buildSummaryMarkdown(summary)
-
- want := "## Intent\n\n" +
- "Rotate session tokens on logout\n\n" +
- "## Outcome\n\n" +
- "Logout now mints a new token\n\n" +
- "## Learnings\n\n" +
- "### Repository\n\n" +
- "- Auth lives behind the auth_v2 gate\n\n" +
- "### Code\n\n" +
- "- `auth/session.go:42` — Rotate before cookie clear\n\n" +
- "### Workflow\n\n" +
- "- Manual curl confirmed the path\n\n" +
- "## Friction\n\n" +
- "- go-git v5 reset deleted .trace\n\n" +
- "## Open Items\n\n" +
- "- Backfill rotation for legacy cookies\n"
-
- if got != want {
- t.Errorf("buildSummaryMarkdown mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
- }
-}
-
-func TestBuildSummaryMarkdown_NoLearnings(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "Trivial fix",
- Outcome: "Fixed",
- }
-
- got := buildSummaryMarkdown(summary)
-
- if strings.Contains(got, "## Learnings") {
- t.Errorf("expected no Learnings heading when all subsections empty, got:\n%s", got)
- }
- if !strings.Contains(got, "## Intent\n\nTrivial fix\n\n") {
- t.Errorf("expected Intent block, got:\n%s", got)
- }
- if !strings.Contains(got, "## Outcome\n\nFixed\n") {
- t.Errorf("expected Outcome block, got:\n%s", got)
- }
-}
-
-func TestBuildSummaryMarkdown_PartialLearnings(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "i",
- Outcome: "o",
- Learnings: checkpoint.LearningsSummary{
- Code: []checkpoint.CodeLearning{
- {Path: "a.go", Finding: "x"},
- },
- },
- }
-
- got := buildSummaryMarkdown(summary)
-
- if !strings.Contains(got, "## Learnings") {
- t.Errorf("expected Learnings heading when Code populated, got:\n%s", got)
- }
- if !strings.Contains(got, "### Code") {
- t.Errorf("expected Code subsection, got:\n%s", got)
- }
- if strings.Contains(got, "### Repository") {
- t.Errorf("did not expect Repository subsection, got:\n%s", got)
- }
- if strings.Contains(got, "### Workflow") {
- t.Errorf("did not expect Workflow subsection, got:\n%s", got)
- }
-}
-
-func TestBuildSummaryMarkdown_CodeLineVariants(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "i",
- Outcome: "o",
- Learnings: checkpoint.LearningsSummary{
- Code: []checkpoint.CodeLearning{
- {Path: "a.go", Line: 10, EndLine: 20, Finding: "range"},
- {Path: "b.go", Line: 5, Finding: "single"},
- {Path: "c.go", Finding: "no-line"},
- },
- },
- }
-
- got := buildSummaryMarkdown(summary)
-
- wantLines := []string{
- "- `a.go:10-20` — range",
- "- `b.go:5` — single",
- "- `c.go` — no-line",
- }
- for _, line := range wantLines {
- if !strings.Contains(got, line) {
- t.Errorf("expected line %q in output, got:\n%s", line, got)
- }
- }
-}
-
-func TestBuildSummaryMarkdown_EmptyFrictionAndOpenItems(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "i",
- Outcome: "o",
- }
-
- got := buildSummaryMarkdown(summary)
-
- if strings.Contains(got, "## Friction") {
- t.Errorf("did not expect Friction heading, got:\n%s", got)
- }
- if strings.Contains(got, "## Open Items") {
- t.Errorf("did not expect Open Items heading, got:\n%s", got)
- }
-}
-
-func TestBuildSummaryMarkdown_BacktickEscape(t *testing.T) {
- t.Parallel()
-
- summary := &checkpoint.Summary{
- Intent: "Use the `foo` command",
- Outcome: "Wrapped in `bar`",
- }
-
- got := buildSummaryMarkdown(summary)
-
- if strings.Contains(got, "`foo`") {
- t.Errorf("expected backticks to be neutralized in Intent, got:\n%s", got)
- }
- if strings.Contains(got, "`bar`") {
- t.Errorf("expected backticks to be neutralized in Outcome, got:\n%s", got)
- }
- if !strings.Contains(got, "Use the ‘foo‘ command") {
- t.Errorf("expected U+2018 substitution in Intent, got:\n%s", got)
- }
-}
-
-func TestBuildSummaryMarkdown_NilSummary(t *testing.T) {
- t.Parallel()
-
- if got := buildSummaryMarkdown(nil); got != "" {
- t.Errorf("expected empty string for nil summary, got %q", got)
- }
-}
-
-func TestBuildFilesMarkdown_RendersPathsAsInlineCode(t *testing.T) {
- t.Parallel()
-
- got := buildFilesMarkdown([]string{
- "normal.go",
- "- tricky [path].go",
- "dir/`quoted`.go",
- })
-
- wantLines := []string{
- "- `normal.go`",
- "- `- tricky [path].go`",
- "- `dir/‘quoted‘.go`",
- }
- for _, line := range wantLines {
- if !strings.Contains(got, line) {
- t.Errorf("expected escaped file line %q in output, got:\n%s", line, got)
- }
- }
-}
-
-func TestFormatCheckpointHeader_FullMetadataPlain(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- summary := &checkpoint.CheckpointSummary{
- TokenUsage: &agent.TokenUsage{InputTokens: 18432},
- }
- meta := checkpoint.CommittedMetadata{
- SessionID: "2026-04-29-7f3c1a",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- commits := []associatedCommit{{
- ShortSHA: "9f2c11a",
- Message: "feat(auth): rotate session tokens on logout",
- Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
- }}
- author := checkpoint.Author{Name: "Peyton Montei", Email: "peyton@trace.io"}
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(summary, meta, cpID, commits, author, styles)
-
- wantLines := []string{
- "● Checkpoint a3b2c4d5e6f7",
- " session 2026-04-29-7f3c1a",
- " created 2026-04-29 14:22:08",
- " author Peyton Montei ",
- " tokens 18.4k",
- " commits 9f2c11a feat(auth): rotate session tokens on logout",
- }
- for _, line := range wantLines {
- if !strings.Contains(got, line) {
- t.Errorf("expected line %q in header, got:\n%s", line, got)
- }
- }
-}
-
-func TestFormatCheckpointHeader_NoAuthor(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
-
- if strings.Contains(got, " author") {
- t.Errorf("did not expect author row when Name empty, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_NoCommits(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
-
- if strings.Contains(got, " commits") {
- t.Errorf("did not expect commits row when commits is nil, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_MultipleCommits(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- commits := []associatedCommit{
- {ShortSHA: "aaa1111", Message: "first", Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)},
- {ShortSHA: "bbb2222", Message: "second", Date: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)},
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(nil, meta, cpID, commits, checkpoint.Author{}, styles)
-
- if !strings.Contains(got, " commits (2)") {
- t.Errorf("expected commits row with count (2), got:\n%s", got)
- }
- if !strings.Contains(got, " aaa1111 2026-04-29 first") {
- t.Errorf("expected first commit line aligned under value column, got:\n%s", got)
- }
- if !strings.Contains(got, " bbb2222 2026-04-29 second") {
- t.Errorf("expected second commit line aligned under value column, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_EmptyCommitsSlice(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(nil, meta, cpID, []associatedCommit{}, checkpoint.Author{}, styles)
-
- if !strings.Contains(got, " commits (none on this branch)") {
- t.Errorf("expected explicit none row when commits slice is empty, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_NoTokenUsage(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, styles)
-
- if strings.Contains(got, " tokens") {
- t.Errorf("did not expect tokens row when both meta and summary are nil, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_TokensFromSummaryFallback(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- TokenUsage: nil,
- }
- summary := &checkpoint.CheckpointSummary{
- TokenUsage: &agent.TokenUsage{InputTokens: 1234},
- }
- styles := statusStyles{colorEnabled: false, width: 60}
-
- got := formatCheckpointHeader(summary, meta, cpID, nil, checkpoint.Author{}, styles)
-
- if !strings.Contains(got, " tokens 1.2k") {
- t.Errorf("expected tokens row from summary fallback, got:\n%s", got)
- }
-}
-
-func TestFormatCheckpointHeader_ColorEnabledRenders(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
- meta := checkpoint.CommittedMetadata{
- SessionID: "s",
- CreatedAt: time.Date(2026, 4, 29, 14, 22, 8, 0, time.UTC),
- TokenUsage: &agent.TokenUsage{InputTokens: 1234},
- }
- plainStyles := statusStyles{colorEnabled: false, width: 60}
- colorStyles := statusStyles{
- colorEnabled: true,
- width: 60,
- bold: lipgloss.NewStyle().Bold(true),
- dim: lipgloss.NewStyle().Faint(true),
- yellow: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
- }
-
- plain := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, plainStyles)
- styled := formatCheckpointHeader(nil, meta, cpID, nil, checkpoint.Author{}, colorStyles)
-
- if !strings.Contains(plain, "●") {
- t.Errorf("expected ● glyph in plain output, got:\n%s", plain)
- }
- if !strings.Contains(styled, "●") {
- t.Errorf("expected ● glyph in styled output, got:\n%s", styled)
- }
- if len(styled) <= len(plain) {
- t.Errorf("expected styled length (%d) > plain length (%d)", len(styled), len(plain))
- }
-}
-
-func TestBuildPagerCmd_LessRInjectedWhenEnvUnset(t *testing.T) {
- oldEnv := pagerLookupEnv
- t.Cleanup(func() { pagerLookupEnv = oldEnv })
-
- pagerLookupEnv = func(key string) string {
- if key == pagerEnvVar || key == lessEnvVar {
- return ""
- }
- return os.Getenv(key)
- }
-
- cmd, pager := buildPagerCmd(context.Background())
-
- if runtime.GOOS == windowsGOOS {
- t.Skip("LESS injection only applies to less on Unix")
- }
- if pager != lessPagerName {
- t.Fatalf("expected resolved pager 'less' on non-Windows, got %q", pager)
- }
-
- found := false
- for _, e := range cmd.Env {
- if e == lessRawControlEnv {
- found = true
- break
- }
- }
- if !found {
- t.Error("expected LESS=-R in cmd.Env")
- }
-}
-
-func TestBuildPagerCmd_ReplacesEmptyLessEnv(t *testing.T) {
- t.Setenv(lessEnvVar, "")
-
- oldEnv := pagerLookupEnv
- t.Cleanup(func() { pagerLookupEnv = oldEnv })
-
- pagerLookupEnv = func(key string) string {
- if key == pagerEnvVar || key == lessEnvVar {
- return ""
- }
- return os.Getenv(key)
- }
-
- cmd, pager := buildPagerCmd(context.Background())
-
- if runtime.GOOS == windowsGOOS {
- t.Skip("LESS injection only applies to less on Unix")
- }
- if pager != lessPagerName {
- t.Fatalf("expected resolved pager 'less' on non-Windows, got %q", pager)
- }
-
- lessEntries := 0
- for _, e := range cmd.Env {
- if strings.HasPrefix(e, lessEnvVar+"=") {
- lessEntries++
- if e != lessRawControlEnv {
- t.Errorf("expected %s, got %q", lessRawControlEnv, e)
- }
- }
- }
- if lessEntries != 1 {
- t.Errorf("expected exactly one LESS entry, got %d", lessEntries)
- }
-}
-
-func TestBuildPagerCmd_LessRSkippedWhenLessEnvSet(t *testing.T) {
- oldEnv := pagerLookupEnv
- t.Cleanup(func() { pagerLookupEnv = oldEnv })
-
- pagerLookupEnv = func(key string) string {
- switch key {
- case pagerEnvVar:
- return ""
- case lessEnvVar:
- return "-FRX"
- default:
- return os.Getenv(key)
- }
- }
-
- cmd, _ := buildPagerCmd(context.Background())
-
- for _, e := range cmd.Env {
- if e == lessRawControlEnv {
- t.Error("did not expect LESS=-R when user set LESS=-FRX")
- }
- }
-}
-
-func TestBuildPagerCmd_HonorsCustomPager(t *testing.T) {
- oldEnv := pagerLookupEnv
- t.Cleanup(func() { pagerLookupEnv = oldEnv })
-
- pagerLookupEnv = func(key string) string {
- if key == pagerEnvVar {
- return "bat"
- }
- return os.Getenv(key)
- }
-
- cmd, pager := buildPagerCmd(context.Background())
-
- if pager != "bat" {
- t.Errorf("expected resolved pager 'bat', got %q", pager)
- }
- for _, e := range cmd.Env {
- if e == lessRawControlEnv {
- t.Error("did not expect LESS=-R when user picked a custom pager")
- }
- }
-}
-
-func TestFormatBranchCheckpoints_BasicOutput(t *testing.T) {
- now := time.Now()
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Add feature X",
- Date: now,
- CheckpointID: "chk123456789",
- SessionID: "2026-01-22-session-1",
- SessionPrompt: "Implement feature X",
- },
- {
- ID: "def456ghi789",
- Message: "Fix bug in Y",
- Date: now.Add(-time.Hour),
- CheckpointID: "chk987654321",
- SessionID: "2026-01-22-session-2",
- SessionPrompt: "Fix the bug",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "feature/my-branch", points, "")
-
- // Should show branch name
- if !strings.Contains(output, "feature/my-branch") {
- t.Errorf("expected branch name in output, got:\n%s", output)
- }
-
- // Should show checkpoint count (new metadata-row shape)
- if !strings.Contains(output, "checkpoints 2") {
- t.Errorf("expected 'checkpoints 2' in output, got:\n%s", output)
- }
-
- // Should show checkpoint messages
- if !strings.Contains(output, "Add feature X") {
- t.Errorf("expected first checkpoint message in output, got:\n%s", output)
- }
- if !strings.Contains(output, "Fix bug in Y") {
- t.Errorf("expected second checkpoint message in output, got:\n%s", output)
- }
-}
-
-func TestFormatBranchCheckpoints_GroupedByCheckpointID(t *testing.T) {
- // Create checkpoints spanning multiple days
- today := time.Date(2026, 1, 22, 10, 0, 0, 0, time.UTC)
- yesterday := time.Date(2026, 1, 21, 14, 0, 0, 0, time.UTC)
-
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Today checkpoint 1",
- Date: today,
- CheckpointID: "chk111111111",
- SessionID: "2026-01-22-session-1",
- SessionPrompt: "First task today",
- },
- {
- ID: "def456ghi789",
- Message: "Today checkpoint 2",
- Date: today.Add(-30 * time.Minute),
- CheckpointID: "chk222222222",
- SessionID: "2026-01-22-session-1",
- SessionPrompt: "First task today",
- },
- {
- ID: "ghi789jkl012",
- Message: "Yesterday checkpoint",
- Date: yesterday,
- CheckpointID: "chk333333333",
- SessionID: "2026-01-21-session-2",
- SessionPrompt: "Task from yesterday",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Should group by checkpoint ID - check for checkpoint headers (identity bullet)
- if !strings.Contains(output, "● chk111111111") {
- t.Errorf("expected checkpoint ID header in output, got:\n%s", output)
- }
- if !strings.Contains(output, "● chk333333333") {
- t.Errorf("expected checkpoint ID header in output, got:\n%s", output)
- }
-
- // Dates should appear inline with commits (format MM-DD)
- if !strings.Contains(output, "01-22") {
- t.Errorf("expected today's date inline with commits, got:\n%s", output)
- }
- if !strings.Contains(output, "01-21") {
- t.Errorf("expected yesterday's date inline with commits, got:\n%s", output)
- }
-
- // Today's checkpoints should appear before yesterday's (sorted by latest timestamp)
- todayIdx := strings.Index(output, "chk111111111")
- yesterdayIdx := strings.Index(output, "chk333333333")
- if todayIdx == -1 || yesterdayIdx == -1 || todayIdx > yesterdayIdx {
- t.Errorf("expected today's checkpoints before yesterday's, got:\n%s", output)
- }
-}
-
-func TestFormatBranchCheckpoints_NoCheckpoints(t *testing.T) {
- output := formatBranchCheckpoints(io.Discard, "feature/empty-branch", nil, "")
-
- // Should show branch name
- if !strings.Contains(output, "feature/empty-branch") {
- t.Errorf("expected branch name in output, got:\n%s", output)
- }
-
- // Should indicate no checkpoints (new metadata-row shape: "checkpoints 0")
- if !strings.Contains(output, "checkpoints 0") && !strings.Contains(output, "No checkpoints") {
- t.Errorf("expected indication of no checkpoints, got:\n%s", output)
- }
-}
-
-func TestFormatBranchCheckpoints_ShowsSessionInfo(t *testing.T) {
- now := time.Now()
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Test checkpoint",
- Date: now,
- CheckpointID: "chk123456789",
- SessionID: "2026-01-22-test-session",
- SessionPrompt: "This is my test prompt",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Should show session prompt
- if !strings.Contains(output, "This is my test prompt") {
- t.Errorf("expected session prompt in output, got:\n%s", output)
- }
-}
-
-func TestFormatBranchCheckpoints_ShowsTemporaryIndicator(t *testing.T) {
- now := time.Now()
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Committed checkpoint",
- Date: now,
- CheckpointID: "chk123456789",
- IsLogsOnly: true, // Committed = logs only, no indicator shown
- SessionID: "2026-01-22-session-1",
- },
- {
- ID: "def456ghi789",
- Message: "Active checkpoint",
- Date: now.Add(-time.Hour),
- CheckpointID: "chk987654321",
- IsLogsOnly: false, // Temporary = can be rewound, shows [temporary]
- SessionID: "2026-01-22-session-1",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Should indicate temporary (non-committed) checkpoints with [temporary]
- if !strings.Contains(output, "[temporary]") {
- t.Errorf("expected [temporary] indicator for non-committed checkpoint, got:\n%s", output)
- }
-
- // Committed checkpoints should NOT have [temporary] indicator
- // Find the line with the committed checkpoint and verify it doesn't have [temporary]
- lines := strings.Split(output, "\n")
- for _, line := range lines {
- if strings.Contains(line, "chk123456789") && strings.Contains(line, "[temporary]") {
- t.Errorf("committed checkpoint should not have [temporary] indicator, got:\n%s", output)
- }
- }
-}
-
-func TestFormatBranchCheckpoints_ShowsTaskCheckpoints(t *testing.T) {
- now := time.Now()
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Running tests (toolu_01ABC)",
- Date: now,
- CheckpointID: "chk123456789",
- IsTaskCheckpoint: true,
- ToolUseID: "toolu_01ABC",
- SessionID: "2026-01-22-session-1",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Should indicate task checkpoint
- if !strings.Contains(output, "[Task]") && !strings.Contains(output, "task") {
- t.Errorf("expected task checkpoint indicator, got:\n%s", output)
- }
-}
-
-// TestFormatCheckpointGroup_NoPromptNoCommitShowsPlaceholder verifies the
-// (no prompt recorded) placeholder appears only when neither a session prompt
-// nor a commit message is available.
-func TestFormatCheckpointGroup_NoPromptNoCommitShowsPlaceholder(t *testing.T) {
- t.Parallel()
- var sb strings.Builder
- styles := newStatusStyles(io.Discard)
- formatCheckpointGroup(&sb, checkpointGroup{
- checkpointID: "temporary",
- prompt: "",
- isTemporary: true,
- commits: []commitEntry{{date: time.Now(), gitSHA: "deadbee", message: ""}},
- }, styles)
- out := sb.String()
- if !strings.Contains(out, "(no prompt recorded)") {
- t.Errorf("expected '(no prompt recorded)' placeholder:\n%s", out)
- }
-}
-
-// TestFormatCheckpointGroup_FallsBackToCommitMessage verifies the cascade:
-// when SessionPrompt is empty but a commit message is present, the headline
-// renders the commit message bare (not the placeholder).
-func TestFormatCheckpointGroup_FallsBackToCommitMessage(t *testing.T) {
- t.Parallel()
- var sb strings.Builder
- styles := newStatusStyles(io.Discard)
- formatCheckpointGroup(&sb, checkpointGroup{
- checkpointID: "abc123def456",
- prompt: "",
- commits: []commitEntry{{date: time.Now(), gitSHA: "deadbee", message: "feat(cli): wire up paging"}},
- }, styles)
- out := sb.String()
- if !strings.Contains(out, "● abc123def456") {
- t.Errorf("expected identity bullet headline:\n%s", out)
- }
- if !strings.Contains(out, "feat(cli): wire up paging") {
- t.Errorf("expected commit-message fallback in headline:\n%s", out)
- }
- if strings.Contains(out, "(no prompt recorded)") {
- t.Errorf("did not expect dimmed placeholder when commit message available:\n%s", out)
- }
-}
-
-func TestFormatBranchCheckpoints_TruncatesLongMessages(t *testing.T) {
- now := time.Now()
- longMessage := strings.Repeat("a", 200) // 200 character message
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: longMessage,
- Date: now,
- CheckpointID: "chk123456789",
- SessionID: "2026-01-22-session-1",
- },
- }
-
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Output should not contain the full 200 character message
- if strings.Contains(output, longMessage) {
- t.Errorf("expected long message to be truncated, got full message in output")
- }
-
- // Should contain truncation indicator (usually "...")
- if !strings.Contains(output, "...") {
- t.Errorf("expected truncation indicator '...' for long message, got:\n%s", output)
- }
-}
-
-func TestGetBranchCheckpoints_ReadsPromptFromShadowBranch(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo with an initial commit
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create and commit initial file
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- initialCommit, err := w.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- // Create metadata directory with prompt.txt
- sessionID := "2026-01-27-test-session"
- metadataDir := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- expectedPrompt := "This is my test prompt for the checkpoint"
- if err := os.WriteFile(filepath.Join(metadataDir, paths.PromptFileName), []byte(expectedPrompt), 0o644); err != nil {
- t.Fatalf("failed to write prompt file: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create first checkpoint (baseline copy) - this one gets filtered out
- store := checkpoint.NewGitStore(repo)
- baseCommit := initialCommit.String()[:7]
- _, err = store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
- SessionID: sessionID,
- BaseCommit: baseCommit,
- ModifiedFiles: []string{"test.txt"},
- MetadataDir: ".trace/metadata/" + sessionID,
- MetadataDirAbs: metadataDir,
- CommitMessage: "First checkpoint (baseline)",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: true,
- })
- if err != nil {
- t.Fatalf("WriteTemporary() first checkpoint error = %v", err)
- }
-
- // Modify test file again for a second checkpoint with actual code changes
- if err := os.WriteFile(testFile, []byte("second modification"), 0o644); err != nil {
- t.Fatalf("failed to modify test file: %v", err)
- }
-
- // Create second checkpoint (has code changes, won't be filtered)
- _, err = store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
- SessionID: sessionID,
- BaseCommit: baseCommit,
- ModifiedFiles: []string{"test.txt"},
- MetadataDir: ".trace/metadata/" + sessionID,
- MetadataDirAbs: metadataDir,
- CommitMessage: "Second checkpoint with code changes",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- IsFirstCheckpoint: false, // Not first, has parent
- })
- if err != nil {
- t.Fatalf("WriteTemporary() second checkpoint error = %v", err)
- }
-
- // Now call getBranchCheckpoints and verify the prompt is read
- points, err := getBranchCheckpoints(context.Background(), repo, 10)
- if err != nil {
- t.Fatalf("getBranchCheckpoints() error = %v", err)
- }
-
- // Should have at least one temporary checkpoint (the second one with code changes)
- var foundTempCheckpoint bool
- for _, point := range points {
- if !point.IsLogsOnly && point.SessionID == sessionID {
- foundTempCheckpoint = true
- // Verify the prompt was read correctly from the shadow branch tree
- if point.SessionPrompt != expectedPrompt {
- t.Errorf("expected prompt %q, got %q", expectedPrompt, point.SessionPrompt)
- }
- break
- }
- }
-
- if !foundTempCheckpoint {
- t.Errorf("expected to find temporary checkpoint with session ID %s, got points: %+v", sessionID, points)
- }
-}
-
-func TestGetCurrentWorktreeHash_MainWorktree(t *testing.T) {
- // In a temp dir with a real .git directory (main worktree), getCurrentWorktreeHash
- // should return the hash of empty string (main worktree ID is "").
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
-
- hash := getCurrentWorktreeHash(context.Background())
- expected := checkpoint.HashWorktreeID("") // Main worktree has empty ID
- if hash != expected {
- t.Errorf("getCurrentWorktreeHash(context.Background()) = %q, want %q (hash of empty worktree ID)", hash, expected)
- }
-}
-
-func TestGetReachableTemporaryCheckpoints_FiltersByWorktree(t *testing.T) {
- // Shadow branches are namespaced by worktree hash (trace/-).
- // Only shadow branches matching the current worktree should be included.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Setup metadata for both sessions
- sessionIDLocal := "2026-02-10-local-session"
- sessionIDOther := "2026-02-10-other-session"
- for _, sid := range []string{sessionIDLocal, sessionIDOther} {
- metaDir := filepath.Join(tmpDir, ".trace", "metadata", sid)
- if err := os.MkdirAll(metaDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metaDir, paths.PromptFileName), []byte("test"), 0o644); err != nil {
- t.Fatalf("failed to write prompt: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metaDir, "full.jsonl"), []byte(`{"test":true}`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
- }
-
- store := checkpoint.NewGitStore(repo)
- baseCommit := initialCommit.String()[:7]
-
- writeCheckpoints := func(sessionID, worktreeID string) {
- t.Helper()
- metaDirAbs := filepath.Join(tmpDir, ".trace", "metadata", sessionID)
- // Baseline
- if _, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
- SessionID: sessionID, BaseCommit: baseCommit, WorktreeID: worktreeID,
- ModifiedFiles: []string{"test.txt"}, MetadataDir: ".trace/metadata/" + sessionID,
- MetadataDirAbs: metaDirAbs, CommitMessage: "baseline", AuthorName: "Test",
- AuthorEmail: "test@test.com", IsFirstCheckpoint: true,
- }); err != nil {
- t.Fatalf("WriteTemporary baseline error: %v", err)
- }
- // Code change checkpoint
- if err := os.WriteFile(testFile, []byte(sessionID+" changes"), 0o644); err != nil {
- t.Fatalf("failed to modify test file: %v", err)
- }
- if _, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{
- SessionID: sessionID, BaseCommit: baseCommit, WorktreeID: worktreeID,
- ModifiedFiles: []string{"test.txt"}, MetadataDir: ".trace/metadata/" + sessionID,
- MetadataDirAbs: metaDirAbs, CommitMessage: "code changes", AuthorName: "Test",
- AuthorEmail: "test@test.com", IsFirstCheckpoint: false,
- }); err != nil {
- t.Fatalf("WriteTemporary code changes error: %v", err)
- }
- }
-
- writeCheckpoints(sessionIDLocal, "") // Main worktree (matches test env)
- writeCheckpoints(sessionIDOther, "other-worktree") // Different worktree
-
- // getBranchCheckpoints should only include local worktree's checkpoints
- points, err := getBranchCheckpoints(context.Background(), repo, 20)
- if err != nil {
- t.Fatalf("getBranchCheckpoints error: %v", err)
- }
-
- for _, p := range points {
- if p.SessionID == sessionIDOther {
- t.Errorf("found checkpoint from other worktree (session %s) - should be filtered out", sessionIDOther)
- }
- }
- var foundLocal bool
- for _, p := range points {
- if p.SessionID == sessionIDLocal {
- foundLocal = true
- }
- }
- if !foundLocal {
- t.Errorf("expected local worktree checkpoint (session %s), got: %+v", sessionIDLocal, points)
- }
-}
-
-// TestRunExplainBranchDefault_ShowsBranchCheckpoints is covered by TestExplainDefault_ShowsBranchView
-// since runExplainDefault now calls runExplainBranchDefault directly.
-
-func TestRunExplainBranchDefault_DetachedHead(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo with a commit
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitHash, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Checkout to detached HEAD state
- if err := w.Checkout(&git.CheckoutOptions{Hash: commitHash}); err != nil {
- t.Fatalf("failed to checkout detached HEAD: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- var stdout bytes.Buffer
- err = runExplainBranchDefault(context.Background(), &stdout, true)
- // Should NOT error
- if err != nil {
- t.Errorf("expected no error, got: %v", err)
- }
-
- output := stdout.String()
-
- // Should indicate detached HEAD state in branch name
- if !strings.Contains(output, "HEAD") && !strings.Contains(output, "detached") {
- t.Errorf("expected output to indicate detached HEAD state, got: %s", output)
- }
-}
-
-func TestIsAncestorOf(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("v1"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commit1, err := w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com"},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create second commit
- if err := os.WriteFile(testFile, []byte("v2"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commit2, err := w.Commit("second commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com"},
- })
- if err != nil {
- t.Fatalf("failed to create second commit: %v", err)
- }
-
- t.Run("commit is ancestor of later commit", func(t *testing.T) {
- // commit1 should be an ancestor of commit2
- if !strategy.IsAncestorOf(context.Background(), repo, commit1, commit2) {
- t.Error("expected commit1 to be ancestor of commit2")
- }
- })
-
- t.Run("commit is not ancestor of earlier commit", func(t *testing.T) {
- // commit2 should NOT be an ancestor of commit1
- if strategy.IsAncestorOf(context.Background(), repo, commit2, commit1) {
- t.Error("expected commit2 to NOT be ancestor of commit1")
- }
- })
-
- t.Run("commit is ancestor of itself", func(t *testing.T) {
- // A commit should be considered an ancestor of itself
- if !strategy.IsAncestorOf(context.Background(), repo, commit1, commit1) {
- t.Error("expected commit to be ancestor of itself")
- }
- })
-}
-
-func TestGetBranchCheckpoints_OnFeatureBranch(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit on main
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com"},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- // Get checkpoints (should be empty, but shouldn't error)
- points, err := getBranchCheckpoints(context.Background(), repo, 20)
- if err != nil {
- t.Fatalf("getBranchCheckpoints() error = %v", err)
- }
-
- // Should return empty list (no checkpoints yet)
- if len(points) != 0 {
- t.Errorf("expected 0 checkpoints, got %d", len(points))
- }
-}
-
-func TestHasCodeChanges_FirstCommitReturnsTrue(t *testing.T) {
- // First commit on a shadow branch (no parent) should return true
- // since it captures the working copy state - real uncommitted work
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit (has no parent)
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitHash, err := w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- // First commit (no parent) captures working copy state - should return true
- if !hasCodeChanges(commit) {
- t.Error("hasCodeChanges() should return true for first commit (captures working copy)")
- }
-}
-
-func TestHasCodeChanges_OnlyMetadataChanges(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create second commit with only .trace/ metadata changes
- metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write metadata file: %v", err)
- }
- if _, err := w.Add(".trace"); err != nil {
- t.Fatalf("failed to add .trace: %v", err)
- }
- commitHash, err := w.Commit("metadata only commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create second commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- // Only .trace/ changes should return false
- if hasCodeChanges(commit) {
- t.Error("hasCodeChanges() should return false when only .trace/ files changed")
- }
-}
-
-func TestHasCodeChanges_WithCodeChanges(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create second commit with code changes
- if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
- t.Fatalf("failed to modify test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add modified file: %v", err)
- }
- commitHash, err := w.Commit("code change commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create second commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- // Code changes should return true
- if !hasCodeChanges(commit) {
- t.Error("hasCodeChanges() should return true when code files changed")
- }
-}
-
-func TestHasCodeChanges_MixedChanges(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create second commit with BOTH code and metadata changes
- if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
- t.Fatalf("failed to modify test file: %v", err)
- }
- metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write metadata file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- if _, err := w.Add(".trace"); err != nil {
- t.Fatalf("failed to add .trace: %v", err)
- }
- commitHash, err := w.Commit("mixed changes commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create second commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- // Mixed changes should return true (code changes present)
- if !hasCodeChanges(commit) {
- t.Error("hasCodeChanges() should return true when commit has both code and metadata changes")
- }
-}
-
-func TestGetBranchCheckpoints_FiltersMainCommits(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit on master (go-git default)
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- mainCommit, err := w.Commit("main commit with Trace-Checkpoint: abc123def456", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com"},
- })
- if err != nil {
- t.Fatalf("failed to create main commit: %v", err)
- }
-
- // Create feature branch
- featureBranch := "feature/test"
- if err := w.Checkout(&git.CheckoutOptions{
- Hash: mainCommit,
- Branch: plumbing.NewBranchReferenceName(featureBranch),
- Create: true,
- }); err != nil {
- t.Fatalf("failed to create feature branch: %v", err)
- }
-
- // Create commit on feature branch
- if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("feature commit with Trace-Checkpoint: def456ghi789", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com"},
- })
- if err != nil {
- t.Fatalf("failed to create feature commit: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- // Get checkpoints - should only include feature branch commits, not main
- // Note: Without actual checkpoint data in trace/checkpoints/v1, this returns empty
- // but the important thing is it doesn't error and the filtering logic runs
- points, err := getBranchCheckpoints(context.Background(), repo, 20)
- if err != nil {
- t.Fatalf("getBranchCheckpoints() error = %v", err)
- }
-
- // Without checkpoint data (no trace/checkpoints/v1 branch), should return 0 checkpoints
- // This validates the filtering code path runs without error
- if len(points) != 0 {
- t.Errorf("expected 0 checkpoints without checkpoint data, got %d", len(points))
- }
-}
-
-func TestScopeTranscriptForCheckpoint_SlicesTranscript(t *testing.T) {
- // Transcript with 5 lines - prompts 1, 2, 3 with their responses
- fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"prompt 1"}}
-{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"response 1"}]}}
-{"type":"user","uuid":"u2","message":{"content":"prompt 2"}}
-{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"response 2"}]}}
-{"type":"user","uuid":"u3","message":{"content":"prompt 3"}}
-`)
-
- // Checkpoint starts at line 2 (after prompt 1 and response 1)
- // Should only include lines 2-4 (prompt 2, response 2, prompt 3)
- scoped := scopeTranscriptForCheckpoint(fullTranscript, 2, agent.AgentTypeClaudeCode)
-
- // Parse the scoped transcript to verify content
- lines, err := transcript.ParseFromBytes(scoped)
- if err != nil {
- t.Fatalf("failed to parse scoped transcript: %v", err)
- }
-
- if len(lines) != 3 {
- t.Fatalf("expected 3 lines in scoped transcript, got %d", len(lines))
- }
-
- // First line should be prompt 2 (u2), not prompt 1
- if lines[0].UUID != "u2" {
- t.Errorf("expected first line to be u2 (prompt 2), got %s", lines[0].UUID)
- }
-
- // Last line should be prompt 3 (u3)
- if lines[2].UUID != "u3" {
- t.Errorf("expected last line to be u3 (prompt 3), got %s", lines[2].UUID)
- }
-}
-
-func TestScopeTranscriptForCheckpoint_ZeroLinesReturnsAll(t *testing.T) {
- transcriptData := []byte(`{"type":"user","uuid":"u1","message":{"content":"prompt 1"}}
-{"type":"user","uuid":"u2","message":{"content":"prompt 2"}}
-`)
-
- // With linesAtStart=0, should return full transcript
- scoped := scopeTranscriptForCheckpoint(transcriptData, 0, agent.AgentTypeClaudeCode)
-
- lines, err := transcript.ParseFromBytes(scoped)
- if err != nil {
- t.Fatalf("failed to parse scoped transcript: %v", err)
- }
-
- if len(lines) != 2 {
- t.Fatalf("expected 2 lines with linesAtStart=0, got %d", len(lines))
- }
-}
-
-func TestScopeTranscriptForCheckpoint_CodexUsesStoredLineOffsets(t *testing.T) {
- t.Parallel()
-
- fullTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
-{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions"}]}}
-{"timestamp":"t3","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md\ninstructions"}]}}
-{"timestamp":"t4","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first prompt"}]}}
-{"timestamp":"t5","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to first"}]}}
-{"timestamp":"t6","type":"event_msg","payload":{"type":"token_count","input_tokens":10,"output_tokens":1}}
-{"timestamp":"t7","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second prompt"}]}}
-{"timestamp":"t8","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to second"}]}}
-`)
-
- scoped := scopeTranscriptForCheckpoint(fullTranscript, 6, agent.AgentTypeCodex)
- entries, err := summarize.BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(scoped), agent.AgentTypeCodex)
- if err != nil {
- t.Fatalf("failed to build condensed transcript: %v", err)
- }
-
- if len(entries) != 2 {
- t.Fatalf("expected 2 scoped entries, got %d", len(entries))
- }
-
- if entries[0].Type != summarize.EntryTypeUser || entries[0].Content != "second prompt" {
- t.Fatalf("expected first entry to be second prompt, got %#v", entries[0])
- }
-
- if entries[1].Type != summarize.EntryTypeAssistant || entries[1].Content != "response to second" {
- t.Fatalf("expected second entry to be second response, got %#v", entries[1])
- }
-}
-
-func TestExtractPromptsFromScopedTranscript(t *testing.T) {
- // Transcript with 4 lines - 2 user prompts, 2 assistant responses
- transcript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt"}}
-{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
-{"type":"user","uuid":"u2","message":{"content":"Second prompt"}}
-{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
-`)
-
- prompts := extractPromptsFromTranscript(transcript, "")
-
- if len(prompts) != 2 {
- t.Fatalf("expected 2 prompts, got %d", len(prompts))
- }
-
- if prompts[0] != "First prompt" {
- t.Errorf("expected first prompt 'First prompt', got %q", prompts[0])
- }
-
- if prompts[1] != "Second prompt" {
- t.Errorf("expected second prompt 'Second prompt', got %q", prompts[1])
- }
-}
-
-func TestFormatCheckpointOutput_UsesScopedPrompts(t *testing.T) {
- // Full transcript with 4 lines (2 prompts + 2 responses)
- // Checkpoint starts at line 2 (should only show second prompt)
- fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt - should NOT appear"}}
-{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
-{"type":"user","uuid":"u2","message":{"content":"Second prompt - SHOULD appear"}}
-{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
-`)
-
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-30-test-session",
- CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 2, // Checkpoint starts at line 2
- },
- Prompts: "First prompt - should NOT appear\nSecond prompt - SHOULD appear", // Full prompts (not scoped yet)
- Transcript: fullTranscript,
- }
-
- // Verbose output should use scoped prompts
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Should show ONLY the second prompt (scoped)
- if !strings.Contains(output, "Second prompt - SHOULD appear") {
- t.Errorf("expected scoped prompt in output, got:\n%s", output)
- }
-
- // Should NOT show the first prompt (it's before this checkpoint's scope)
- if strings.Contains(output, "First prompt - should NOT appear") {
- t.Errorf("expected first prompt to be excluded from scoped output, got:\n%s", output)
- }
-}
-
-func TestFormatCheckpointOutput_FallsBackToStoredPrompts(t *testing.T) {
- // Test backwards compatibility: when no transcript exists, use stored prompts
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-30-test-session",
- CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 0,
- },
- Prompts: "Stored prompt from older checkpoint",
- Transcript: nil, // No transcript available
- }
-
- // Verbose output should fall back to stored prompts
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Intent should use stored prompt
- if !strings.Contains(output, "Stored prompt from older checkpoint") {
- t.Errorf("expected fallback to stored prompts, got:\n%s", output)
- }
-}
-
-func TestFormatCheckpointOutput_FullShowsTraceTranscript(t *testing.T) {
- // Test that --full mode shows the trace transcript, not scoped
- fullTranscript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt"}}
-{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"First response"}]}}
-{"type":"user","uuid":"u2","message":{"content":"Second prompt"}}
-{"type":"assistant","uuid":"a2","message":{"content":[{"type":"text","text":"Second response"}]}}
-`)
-
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-30-test-session",
- CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 2, // Checkpoint starts at line 2
- },
- Transcript: fullTranscript,
- }
-
- // Full mode should show the ENTIRE transcript (not scoped)
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, checkpoint.Author{}, false, true, &bytes.Buffer{})
-
- // Should show the full transcript including first prompt (even though scoped prompts exclude it)
- if !strings.Contains(output, "First prompt") {
- t.Errorf("expected --full to show trace transcript including first prompt, got:\n%s", output)
- }
- if !strings.Contains(output, "Second prompt") {
- t.Errorf("expected --full to show trace transcript including second prompt, got:\n%s", output)
- }
-}
-
-func TestRunExplainCommit_NoCheckpointTrailer(t *testing.T) {
- // Create test repo with a commit that has no Trace-Checkpoint trailer
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- // Create a commit without checkpoint trailer
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- hash, err := w.Commit("Regular commit without trailer", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- var buf bytes.Buffer
- err = runExplainCommit(context.Background(), &buf, &buf, hash.String()[:7], false, false, false, false, false, false, false)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "✗ No associated Trace checkpoint") {
- t.Errorf("expected styled failure block, got: %s", output)
- }
- if !strings.Contains(output, " reason") {
- t.Errorf("expected reason row, got: %s", output)
- }
-}
-
-func TestRunExplainCommit_WithCheckpointTrailer(t *testing.T) {
- // Create test repo with a commit that has an Trace-Checkpoint trailer
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- // Create a commit with checkpoint trailer
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
-
- // Create commit with checkpoint trailer
- checkpointID := "abc123def456"
- commitMsg := "Feature commit\n\nTrace-Checkpoint: " + checkpointID + "\n"
- hash, err := w.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- var buf bytes.Buffer
- // This should try to look up the checkpoint and fail (checkpoint doesn't exist in store)
- // but it should still attempt the lookup rather than showing commit details
- err = runExplainCommit(context.Background(), &buf, &buf, hash.String()[:7], false, false, false, false, false, false, false)
-
- // Should error because the checkpoint doesn't exist in the store
- if err == nil {
- t.Fatalf("expected error for missing checkpoint in store, got nil")
- }
-
- // Error should mention checkpoint not found
- if !strings.Contains(err.Error(), "checkpoint not found") && !strings.Contains(err.Error(), "abc123def456") {
- t.Errorf("expected error about checkpoint not found, got: %v", err)
- }
-}
-
-func TestFormatBranchCheckpoints_SessionFilter(t *testing.T) {
- now := time.Now()
- points := []strategy.RewindPoint{
- {
- ID: "abc123def456",
- Message: "Checkpoint from session 1",
- Date: now,
- CheckpointID: "chk111111111",
- SessionID: "2026-01-22-session-alpha",
- SessionPrompt: "Task for session alpha",
- },
- {
- ID: "def456ghi789",
- Message: "Checkpoint from session 2",
- Date: now.Add(-time.Hour),
- CheckpointID: "chk222222222",
- SessionID: "2026-01-22-session-beta",
- SessionPrompt: "Task for session beta",
- },
- {
- ID: "ghi789jkl012",
- Message: "Another checkpoint from session 1",
- Date: now.Add(-2 * time.Hour),
- CheckpointID: "chk333333333",
- SessionID: "2026-01-22-session-alpha",
- SessionPrompt: "Another task for session alpha",
- },
- }
-
- t.Run("no filter shows all checkpoints", func(t *testing.T) {
- output := formatBranchCheckpoints(io.Discard, "main", points, "")
-
- // Should show all checkpoints (new metadata-row shape)
- if !strings.Contains(output, "checkpoints 3") {
- t.Errorf("expected 'checkpoints 3' in output, got:\n%s", output)
- }
- // Should show prompts from both sessions
- if !strings.Contains(output, "Task for session alpha") {
- t.Errorf("expected alpha session prompt in output, got:\n%s", output)
- }
- if !strings.Contains(output, "Task for session beta") {
- t.Errorf("expected beta session prompt in output, got:\n%s", output)
- }
- })
-
- t.Run("filter by exact session ID", func(t *testing.T) {
- output := formatBranchCheckpoints(io.Discard, "main", points, "2026-01-22-session-alpha")
-
- // Should show only alpha checkpoints (2 of them)
- if !strings.Contains(output, "checkpoints 2") {
- t.Errorf("expected 'checkpoints 2' in output, got:\n%s", output)
- }
- if !strings.Contains(output, "Task for session alpha") {
- t.Errorf("expected alpha session prompt in output, got:\n%s", output)
- }
- // Should NOT contain beta session prompt
- if strings.Contains(output, "Task for session beta") {
- t.Errorf("expected output to NOT contain beta session prompt, got:\n%s", output)
- }
- // Should show filter info as a metadata row (label aligned to widest "checkpoints")
- if !strings.Contains(output, "session 2026-01-22-session-alpha") {
- t.Errorf("expected 'session ... 2026-01-22-session-alpha' in output, got:\n%s", output)
- }
- })
-
- t.Run("filter by session ID prefix", func(t *testing.T) {
- output := formatBranchCheckpoints(io.Discard, "main", points, "2026-01-22-session-b")
-
- // Should show only beta checkpoint (1)
- if !strings.Contains(output, "checkpoints 1") {
- t.Errorf("expected 'checkpoints 1' in output, got:\n%s", output)
- }
- if !strings.Contains(output, "Task for session beta") {
- t.Errorf("expected beta session prompt in output, got:\n%s", output)
- }
- })
-
- t.Run("filter with no matches", func(t *testing.T) {
- output := formatBranchCheckpoints(io.Discard, "main", points, "nonexistent-session")
-
- // Should show 0 checkpoints
- if !strings.Contains(output, "checkpoints 0") {
- t.Errorf("expected 'checkpoints 0' in output, got:\n%s", output)
- }
- // Should show filter info even with no matches (label aligned to widest "checkpoints")
- if !strings.Contains(output, "session nonexistent-session") {
- t.Errorf("expected 'session ... nonexistent-session' in output, got:\n%s", output)
- }
- })
-}
-
-func TestRunExplain_SessionFlagFiltersListView(t *testing.T) {
- // Test that --session alone (without --checkpoint or --commit) filters the list view.
- // This is a unit test for the routing logic.
- // Use a fresh git repo so we don't walk the real repo's shadow branches (which is slow).
- tmp := t.TempDir()
- for _, args := range [][]string{
- {"init"},
- {"config", "user.email", "test@test.com"},
- {"config", "user.name", "Test User"},
- {"commit", "--allow-empty", "-m", "init"},
- } {
- cmd := exec.CommandContext(context.Background(), "git", args...)
- cmd.Dir = tmp
- cmd.Env = testutil.GitIsolatedEnv()
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git %v: %v\n%s", args, err, out)
- }
- }
- t.Chdir(tmp)
-
- var buf, errBuf bytes.Buffer
-
- // When session is specified alone, it should NOT error for mutual exclusivity
- // It should route to the list view with a filter (which may fail for other reasons
- // like not being in a git repo, but not for mutual exclusivity)
- err := runExplain(context.Background(), &buf, &errBuf, "some-session", "", "", "", false, false, false, false, false, false, false)
-
- // Should NOT be a mutual exclusivity error
- if err != nil && strings.Contains(err.Error(), "cannot specify multiple") {
- t.Errorf("--session alone should not trigger mutual exclusivity error, got: %v", err)
- }
-}
-
-func TestRunExplain_SessionWithCheckpointStillMutuallyExclusive(t *testing.T) {
- // Test that --session with --checkpoint is still an error
- var buf, errBuf bytes.Buffer
-
- err := runExplain(context.Background(), &buf, &errBuf, "some-session", "", "some-checkpoint", "", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error when --session and --checkpoint both specified")
- }
- if !strings.Contains(err.Error(), "cannot specify multiple") {
- t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
- }
-}
-
-func TestRunExplain_SessionWithCommitStillMutuallyExclusive(t *testing.T) {
- // Test that --session with --commit is still an error
- var buf, errBuf bytes.Buffer
-
- err := runExplain(context.Background(), &buf, &errBuf, "some-session", "some-commit", "", "", false, false, false, false, false, false, false)
-
- if err == nil {
- t.Error("expected error when --session and --commit both specified")
- }
- if !strings.Contains(err.Error(), "cannot specify multiple") {
- t.Errorf("expected 'cannot specify multiple' error, got: %v", err)
- }
-}
-
-func TestFormatCheckpointOutput_WithAuthor(t *testing.T) {
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-30-test-session",
- CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 0,
- },
- Prompts: "Add a new feature",
- Transcript: nil, // No transcript available
- }
-
- author := checkpoint.Author{
- Name: "Alice Developer",
- Email: "alice@example.com",
- }
-
- // With author, should show author line
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, author, true, false, &bytes.Buffer{})
-
- if !strings.Contains(output, " author Alice Developer ") {
- t.Errorf("expected author line in output, got:\n%s", output)
- }
-}
-
-func TestFormatCheckpointOutput_EmptyAuthor(t *testing.T) {
- // Test backwards compatibility: when no transcript exists, use stored prompts
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-01-30-test-session",
- CreatedAt: time.Date(2026, 1, 30, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 0,
- },
- Prompts: "Add a new feature",
- Transcript: nil, // No transcript available
- }
-
- // Empty author - should not show author line
- author := checkpoint.Author{}
-
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), nil, author, true, false, &bytes.Buffer{})
-
- if strings.Contains(output, " author") {
- t.Errorf("expected no author line for empty author, got:\n%s", output)
- }
-}
-
-func TestGetAssociatedCommits(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- checkpointID := id.MustCheckpointID("abc123def456")
-
- // Create first commit without checkpoint trailer
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now().Add(-2 * time.Hour),
- },
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create commit with matching checkpoint trailer
- if err := os.WriteFile(testFile, []byte("with checkpoint"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitMsg := trailers.FormatCheckpoint("feat: add feature", checkpointID)
- _, err = w.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Alice Developer",
- Email: "alice@example.com",
- When: time.Now().Add(-1 * time.Hour),
- },
- })
- if err != nil {
- t.Fatalf("failed to create checkpoint commit: %v", err)
- }
-
- // Create another commit without checkpoint trailer
- if err := os.WriteFile(testFile, []byte("after checkpoint"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("unrelated commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- if err != nil {
- t.Fatalf("failed to create unrelated commit: %v", err)
- }
-
- // Test: should find the one commit with matching checkpoint
- commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits error: %v", err)
- }
-
- if len(commits) != 1 {
- t.Fatalf("expected 1 associated commit, got %d", len(commits))
- }
-
- commit := commits[0]
- if commit.Author != "Alice Developer" {
- t.Errorf("expected author 'Alice Developer', got %q", commit.Author)
- }
- if !strings.Contains(commit.Message, "feat: add feature") {
- t.Errorf("expected message to contain 'feat: add feature', got %q", commit.Message)
- }
- if len(commit.ShortSHA) != 7 {
- t.Errorf("expected 7-char short SHA, got %d chars: %q", len(commit.ShortSHA), commit.ShortSHA)
- }
- if len(commit.SHA) != 40 {
- t.Errorf("expected 40-char full SHA, got %d chars", len(commit.SHA))
- }
-}
-
-func TestGetAssociatedCommits_NoMatches(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create commit without checkpoint trailer
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("regular commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- },
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- // Search for a checkpoint ID that doesn't exist (valid format: 12 hex chars)
- checkpointID := id.MustCheckpointID("aaaa11112222")
- commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits error: %v", err)
- }
-
- if len(commits) != 0 {
- t.Errorf("expected 0 associated commits, got %d", len(commits))
- }
-}
-
-func TestGetAssociatedCommits_MultipleMatches(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Initialize git repo
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- checkpointID := id.MustCheckpointID("abc123def456")
-
- // Create initial commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now().Add(-3 * time.Hour),
- },
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create first commit with checkpoint trailer
- if err := os.WriteFile(testFile, []byte("first"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitMsg := trailers.FormatCheckpoint("first checkpoint commit", checkpointID)
- _, err = w.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now().Add(-2 * time.Hour),
- },
- })
- if err != nil {
- t.Fatalf("failed to create first checkpoint commit: %v", err)
- }
-
- // Create second commit with same checkpoint trailer (e.g., amend scenario)
- if err := os.WriteFile(testFile, []byte("second"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitMsg = trailers.FormatCheckpoint("second checkpoint commit", checkpointID)
- _, err = w.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now().Add(-1 * time.Hour),
- },
- })
- if err != nil {
- t.Fatalf("failed to create second checkpoint commit: %v", err)
- }
-
- // Test: should find both commits with matching checkpoint
- commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits error: %v", err)
- }
-
- if len(commits) != 2 {
- t.Fatalf("expected 2 associated commits, got %d", len(commits))
- }
-
- // Should be in reverse chronological order (newest first)
- if !strings.Contains(commits[0].Message, "second") {
- t.Errorf("expected newest commit first, got %q", commits[0].Message)
- }
- if !strings.Contains(commits[1].Message, "first") {
- t.Errorf("expected older commit second, got %q", commits[1].Message)
- }
-}
-
-func TestFormatCheckpointOutput_WithAssociatedCommits(t *testing.T) {
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-02-04-test-session",
- CreatedAt: time.Date(2026, 2, 4, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 0,
- },
- Prompts: "Add a new feature",
- Transcript: nil, // No transcript available
- }
-
- associatedCommits := []associatedCommit{
- {
- SHA: "abc123def4567890abc123def4567890abc12345",
- ShortSHA: "abc123d",
- Message: "feat: add feature",
- Author: "Alice Developer",
- Date: time.Date(2026, 2, 4, 11, 0, 0, 0, time.UTC),
- },
- {
- SHA: "def456abc7890123def456abc7890123def45678",
- ShortSHA: "def456a",
- Message: "fix: update feature",
- Author: "Bob Developer",
- Date: time.Date(2026, 2, 4, 12, 0, 0, 0, time.UTC),
- },
- }
-
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), associatedCommits, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Should show commits section with count
- if !strings.Contains(output, " commits (2)") {
- t.Errorf("expected 'Commits: (2)' in output, got:\n%s", output)
- }
- // Should show commit details
- if !strings.Contains(output, "abc123d") {
- t.Errorf("expected short SHA 'abc123d' in output, got:\n%s", output)
- }
- if !strings.Contains(output, "def456a") {
- t.Errorf("expected short SHA 'def456a' in output, got:\n%s", output)
- }
- if !strings.Contains(output, "feat: add feature") {
- t.Errorf("expected commit message in output, got:\n%s", output)
- }
- if !strings.Contains(output, "fix: update feature") {
- t.Errorf("expected commit message in output, got:\n%s", output)
- }
- // Should show date in format YYYY-MM-DD
- if !strings.Contains(output, "2026-02-04") {
- t.Errorf("expected date in output, got:\n%s", output)
- }
-}
-
-// createMergeCommit creates a merge commit with two parents using go-git plumbing APIs.
-// Returns the merge commit hash.
-func createMergeCommit(t *testing.T, repo *git.Repository, parent1, parent2 plumbing.Hash, treeHash plumbing.Hash, message string) plumbing.Hash {
- t.Helper()
-
- sig := object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now(),
- }
- commit := object.Commit{
- Author: sig,
- Committer: sig,
- Message: message,
- TreeHash: treeHash,
- ParentHashes: []plumbing.Hash{parent1, parent2},
- }
- obj := repo.Storer.NewEncodedObject()
- if err := commit.Encode(obj); err != nil {
- t.Fatalf("failed to encode merge commit: %v", err)
- }
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- t.Fatalf("failed to store merge commit: %v", err)
- }
- return hash
-}
-
-func TestGetBranchCheckpoints_WithMergeFromMain(t *testing.T) {
- // Regression test: when main is merged into a feature branch, getBranchCheckpoints
- // should still find feature branch checkpoints from before the merge.
- // The old repo.Log() approach did a full DAG walk, entering main's history through
- // merge commits and eventually hitting consecutiveMainLimit, silently dropping
- // older feature branch checkpoints.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit on master
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create feature branch from initial commit
- featureBranch := plumbing.NewBranchReferenceName("feature/test")
- if err := w.Checkout(&git.CheckoutOptions{
- Hash: initialCommit,
- Branch: featureBranch,
- Create: true,
- }); err != nil {
- t.Fatalf("failed to create feature branch: %v", err)
- }
-
- // Create first feature checkpoint commit (BEFORE the merge)
- cpID1 := id.MustCheckpointID("aaa111bbb222")
- if err := os.WriteFile(testFile, []byte("feature work 1"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- featureCommit1, err := w.Commit(trailers.FormatCheckpoint("feat: first feature", cpID1), &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create first feature commit: %v", err)
- }
-
- // Switch to master and add commits (simulating work on main)
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: plumbing.NewBranchReferenceName("master"),
- }); err != nil {
- t.Fatalf("failed to checkout master: %v", err)
- }
- if err := os.WriteFile(testFile, []byte("main work"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- mainCommit, err := w.Commit("main: add work", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create main commit: %v", err)
- }
-
- // Switch back to feature branch
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: featureBranch,
- }); err != nil {
- t.Fatalf("failed to checkout feature branch: %v", err)
- }
-
- // Create merge commit: merge main into feature (feature is first parent, main is second parent)
- featureCommitObj, commitObjErr := repo.CommitObject(featureCommit1)
- if commitObjErr != nil {
- t.Fatalf("failed to get feature commit object: %v", commitObjErr)
- }
- featureTree, treeErr := featureCommitObj.Tree()
- if treeErr != nil {
- t.Fatalf("failed to get feature commit tree: %v", treeErr)
- }
- mergeHash := createMergeCommit(t, repo, featureCommit1, mainCommit, featureTree.Hash, "Merge branch 'master' into feature/test")
-
- // Update feature branch ref to point to merge commit
- ref := plumbing.NewHashReference(featureBranch, mergeHash)
- if err := repo.Storer.SetReference(ref); err != nil {
- t.Fatalf("failed to update feature branch ref: %v", err)
- }
-
- // Reset worktree to merge commit
- if err := w.Reset(&git.ResetOptions{Commit: mergeHash, Mode: git.HardReset}); err != nil {
- t.Fatalf("failed to reset to merge: %v", err)
- }
-
- // Create second feature checkpoint commit (AFTER the merge)
- cpID2 := id.MustCheckpointID("ccc333ddd444")
- if err := os.WriteFile(testFile, []byte("feature work 2"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit(trailers.FormatCheckpoint("feat: second feature", cpID2), &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-1 * time.Hour)},
- Parents: []plumbing.Hash{mergeHash},
- Committer: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-1 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create second feature commit: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- // Test getAssociatedCommits - should find BOTH feature checkpoint commits
- // by walking first-parent chain (skipping the merge's second parent into main)
- commits1, err := getAssociatedCommits(context.Background(), repo, cpID1, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits for cpID1 error: %v", err)
- }
- if len(commits1) != 1 {
- t.Errorf("expected 1 commit for cpID1 (first feature checkpoint), got %d", len(commits1))
- }
-
- commits2, err := getAssociatedCommits(context.Background(), repo, cpID2, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits for cpID2 error: %v", err)
- }
- if len(commits2) != 1 {
- t.Errorf("expected 1 commit for cpID2 (second feature checkpoint), got %d", len(commits2))
- }
-}
-
-func TestGetBranchCheckpoints_MergeCommitAtHEAD(t *testing.T) {
- // Test that when HEAD itself is a merge commit, walkFirstParentCommits
- // correctly follows the first parent (feature branch history) and
- // doesn't walk into the second parent (main branch history).
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit on master
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- initialCommit, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create feature branch
- featureBranch := plumbing.NewBranchReferenceName("feature/merge-at-head")
- if err := w.Checkout(&git.CheckoutOptions{
- Hash: initialCommit,
- Branch: featureBranch,
- Create: true,
- }); err != nil {
- t.Fatalf("failed to create feature branch: %v", err)
- }
-
- // Create feature checkpoint commit
- cpID := id.MustCheckpointID("eee555fff666")
- if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- featureCommit, err := w.Commit(trailers.FormatCheckpoint("feat: feature work", cpID), &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create feature commit: %v", err)
- }
-
- // Switch to master and add a commit
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: plumbing.NewBranchReferenceName("master"),
- }); err != nil {
- t.Fatalf("failed to checkout master: %v", err)
- }
- mainFile := filepath.Join(tmpDir, "main.txt")
- if err := os.WriteFile(mainFile, []byte("main work"), 0o644); err != nil {
- t.Fatalf("failed to write main file: %v", err)
- }
- if _, err := w.Add("main.txt"); err != nil {
- t.Fatalf("failed to add main file: %v", err)
- }
- mainCommit, err := w.Commit("main: add work", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-2 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create main commit: %v", err)
- }
-
- // Switch back to feature and create merge commit AT HEAD
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: featureBranch,
- }); err != nil {
- t.Fatalf("failed to checkout feature branch: %v", err)
- }
-
- featureCommitObj, commitObjErr := repo.CommitObject(featureCommit)
- if commitObjErr != nil {
- t.Fatalf("failed to get feature commit object: %v", commitObjErr)
- }
- featureTree, treeErr := featureCommitObj.Tree()
- if treeErr != nil {
- t.Fatalf("failed to get feature commit tree: %v", treeErr)
- }
- mergeHash := createMergeCommit(t, repo, featureCommit, mainCommit, featureTree.Hash, "Merge branch 'master' into feature/merge-at-head")
-
- // Update feature branch ref to merge commit (HEAD IS the merge)
- ref := plumbing.NewHashReference(featureBranch, mergeHash)
- if err := repo.Storer.SetReference(ref); err != nil {
- t.Fatalf("failed to update feature branch ref: %v", err)
- }
-
- // Create .trace directory
- if err := os.MkdirAll(".trace", 0o750); err != nil {
- t.Fatalf("failed to create .trace dir: %v", err)
- }
-
- // HEAD is the merge commit itself.
- // getAssociatedCommits should walk: merge -> featureCommit -> initial
- // and find the checkpoint on featureCommit.
- commits, err := getAssociatedCommits(context.Background(), repo, cpID, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits error: %v", err)
- }
- if len(commits) != 1 {
- t.Fatalf("expected 1 associated commit when HEAD is merge commit, got %d", len(commits))
- }
- if !strings.Contains(commits[0].Message, "feat: feature work") {
- t.Errorf("expected feature commit message, got %q", commits[0].Message)
- }
-}
-
-func TestWalkFirstParentCommits_SkipsMergeParents(t *testing.T) {
- // Verify that walkFirstParentCommits follows only first parents and doesn't
- // enter the second parent (merge source) of merge commits.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit (shared ancestor)
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- initialCommit, err := w.Commit("A: initial", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-5 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create feature branch with one commit
- featureBranch := plumbing.NewBranchReferenceName("feature/walk-test")
- if err := w.Checkout(&git.CheckoutOptions{
- Hash: initialCommit,
- Branch: featureBranch,
- Create: true,
- }); err != nil {
- t.Fatalf("failed to create feature branch: %v", err)
- }
- if err := os.WriteFile(testFile, []byte("feature"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- featureCommit, err := w.Commit("B: feature work", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create feature commit: %v", err)
- }
-
- // Create main branch commit (will be merge source)
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: plumbing.NewBranchReferenceName("master"),
- }); err != nil {
- t.Fatalf("failed to checkout master: %v", err)
- }
- mainFile := filepath.Join(tmpDir, "main.txt")
- if err := os.WriteFile(mainFile, []byte("main"), 0o644); err != nil {
- t.Fatalf("failed to write main file: %v", err)
- }
- if _, err := w.Add("main.txt"); err != nil {
- t.Fatalf("failed to add main file: %v", err)
- }
- mainCommit, err := w.Commit("C: main work", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-3 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create main commit: %v", err)
- }
-
- // Switch to feature and create merge commit
- if err := w.Checkout(&git.CheckoutOptions{
- Branch: featureBranch,
- }); err != nil {
- t.Fatalf("failed to checkout feature: %v", err)
- }
- featureCommitObj, commitObjErr := repo.CommitObject(featureCommit)
- if commitObjErr != nil {
- t.Fatalf("failed to get feature commit object: %v", commitObjErr)
- }
- featureTree, treeErr := featureCommitObj.Tree()
- if treeErr != nil {
- t.Fatalf("failed to get feature commit tree: %v", treeErr)
- }
- mergeHash := createMergeCommit(t, repo, featureCommit, mainCommit, featureTree.Hash, "M: merge main into feature")
-
- // Walk should visit: M (merge) -> B (feature) -> A (initial)
- // It should NOT visit C (main work), because that's the second parent of the merge.
- var visited []string
- err = walkFirstParentCommits(context.Background(), repo, mergeHash, 0, func(c *object.Commit) error {
- visited = append(visited, strings.Split(c.Message, "\n")[0])
- return nil
- })
- if err != nil {
- t.Fatalf("walkFirstParentCommits error: %v", err)
- }
-
- expected := []string{"M: merge main into feature", "B: feature work", "A: initial"}
- if len(visited) != len(expected) {
- t.Fatalf("expected %d commits visited, got %d: %v", len(expected), len(visited), visited)
- }
- for i, msg := range expected {
- if visited[i] != msg {
- t.Errorf("commit %d: expected %q, got %q", i, msg, visited[i])
- }
- }
-
- // Verify C was NOT visited
- for _, msg := range visited {
- if strings.Contains(msg, "C: main work") {
- t.Error("walkFirstParentCommits visited main branch commit (second parent of merge) - should only follow first parents")
- }
- }
-}
-
-func TestFormatCheckpointOutput_NoCommitsOnBranch(t *testing.T) {
- summary := &checkpoint.CheckpointSummary{
- CheckpointID: id.MustCheckpointID("abc123def456"),
- FilesTouched: []string{"main.go"},
- }
- content := &checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- CheckpointID: "abc123def456",
- SessionID: "2026-02-04-test-session",
- CreatedAt: time.Date(2026, 2, 4, 10, 30, 0, 0, time.UTC),
- FilesTouched: []string{"main.go"},
- CheckpointTranscriptStart: 0,
- },
- Prompts: "Add a new feature",
- Transcript: nil, // No transcript available
- }
-
- // No associated commits - use empty slice (not nil) to indicate "searched but found none"
- associatedCommits := []associatedCommit{}
-
- output := formatCheckpointOutput(summary, content, id.MustCheckpointID("abc123def456"), associatedCommits, checkpoint.Author{}, true, false, &bytes.Buffer{})
-
- // Should show message indicating no commits found
- if !strings.Contains(output, " commits (none on this branch)") {
- t.Errorf("expected 'Commits: No commits found on this branch' in output, got:\n%s", output)
- }
-}
-
-func TestGetAssociatedCommits_SearchAllFindsMergedBranchCommits(t *testing.T) {
- // Regression test: --search-all should find checkpoint commits that live on
- // a feature branch merged into main via a true merge commit. These commits
- // are on the second parent of the merge, so first-parent-only traversal
- // won't find them — but --search-all should use full DAG walk.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- checkpointID := id.MustCheckpointID("aabb11223344")
-
- // Create initial commit on main
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- mainBase, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create a "feature branch" commit with checkpoint trailer (will become second parent)
- if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- featureMsg := trailers.FormatCheckpoint("feat: add feature", checkpointID)
- featureCommit, err := w.Commit(featureMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Feature Dev", Email: "dev@example.com", When: time.Now().Add(-3 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create feature commit: %v", err)
- }
-
- // Move HEAD back to mainBase to simulate being on main
- // Create a new commit on "main" that diverges
- if err := os.WriteFile(testFile, []byte("main work"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- mainCommitObj, err := repo.CommitObject(mainBase)
- if err != nil {
- t.Fatalf("failed to get main base commit: %v", err)
- }
- mainTree, err := mainCommitObj.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // Create a second main commit (to diverge from feature)
- mainTip := createCommitWithTree(t, repo, mainTree.Hash, []plumbing.Hash{mainBase}, "main: parallel work")
-
- // Create merge commit: first parent = mainTip, second parent = featureCommit
- featureCommitObj, err := repo.CommitObject(featureCommit)
- if err != nil {
- t.Fatalf("failed to get feature commit: %v", err)
- }
- featureTree, err := featureCommitObj.Tree()
- if err != nil {
- t.Fatalf("failed to get feature tree: %v", err)
- }
- mergeHash := createMergeCommit(t, repo, mainTip, featureCommit, featureTree.Hash, "Merge feature into main")
-
- // Point HEAD at merge commit
- ref := plumbing.NewHashReference("refs/heads/main", mergeHash)
- if err := repo.Storer.SetReference(ref); err != nil {
- t.Fatalf("failed to set HEAD: %v", err)
- }
- headRef := plumbing.NewSymbolicReference("HEAD", "refs/heads/main")
- if err := repo.Storer.SetReference(headRef); err != nil {
- t.Fatalf("failed to set HEAD: %v", err)
- }
-
- // Without --search-all (first-parent only): should NOT find the feature commit
- // because it's on the second parent of the merge
- commits, err := getAssociatedCommits(context.Background(), repo, checkpointID, false)
- if err != nil {
- t.Fatalf("getAssociatedCommits error: %v", err)
- }
- if len(commits) != 0 {
- t.Errorf("expected 0 commits without --search-all (first-parent only), got %d", len(commits))
- }
-
- // With --search-all (full DAG walk): SHOULD find the feature commit
- commits, err = getAssociatedCommits(context.Background(), repo, checkpointID, true)
- if err != nil {
- t.Fatalf("getAssociatedCommits --search-all error: %v", err)
- }
- if len(commits) != 1 {
- t.Fatalf("expected 1 commit with --search-all, got %d", len(commits))
- }
- if commits[0].Author != "Feature Dev" {
- t.Errorf("expected author 'Feature Dev', got %q", commits[0].Author)
- }
-}
-
-func TestGetBranchCheckpoints_DefaultBranchFindsMergedCheckpoints(t *testing.T) {
- // Regression test: on the default branch, getBranchCheckpoints should find
- // checkpoint commits that came in via merge commits (second parents).
- // First-parent-only traversal would miss these.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit on master (this is the default branch)
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- masterBase, err := w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now().Add(-4 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create a feature branch commit with checkpoint trailer
- cpID := id.MustCheckpointID("fea112233344")
- if err := os.WriteFile(testFile, []byte("feature work"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- featureCommit, err := w.Commit(trailers.FormatCheckpoint("feat: add feature", cpID), &git.CommitOptions{
- Author: &object.Signature{Name: "Feature Dev", Email: "dev@example.com", When: time.Now().Add(-3 * time.Hour)},
- })
- if err != nil {
- t.Fatalf("failed to create feature commit: %v", err)
- }
-
- // Get tree hashes for creating commits via plumbing
- masterBaseObj, err := repo.CommitObject(masterBase)
- if err != nil {
- t.Fatalf("failed to get master base: %v", err)
- }
- masterTree, err := masterBaseObj.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
- featureObj, err := repo.CommitObject(featureCommit)
- if err != nil {
- t.Fatalf("failed to get feature commit: %v", err)
- }
- featureTree, err := featureObj.Tree()
- if err != nil {
- t.Fatalf("failed to get feature tree: %v", err)
- }
-
- // Create a second commit on master (diverge from feature)
- masterTip := createCommitWithTree(t, repo, masterTree.Hash, []plumbing.Hash{masterBase}, "main: parallel work")
-
- // Create merge commit on master: first parent = masterTip, second parent = featureCommit
- mergeHash := createMergeCommit(t, repo, masterTip, featureCommit, featureTree.Hash, "Merge feature into master")
-
- // Point master at merge commit
- ref := plumbing.NewHashReference("refs/heads/master", mergeHash)
- if err := repo.Storer.SetReference(ref); err != nil {
- t.Fatalf("failed to set ref: %v", err)
- }
- headRef := plumbing.NewSymbolicReference("HEAD", "refs/heads/master")
- if err := repo.Storer.SetReference(headRef); err != nil {
- t.Fatalf("failed to set HEAD: %v", err)
- }
-
- // Write committed checkpoint metadata so getBranchCheckpoints can find it
- store := checkpoint.NewGitStore(repo)
- if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "test-session",
- Strategy: "manual-commit",
- FilesTouched: []string{"test.txt"},
- Prompts: []string{"add feature"},
- }); err != nil {
- t.Fatalf("failed to write committed checkpoint: %v", err)
- }
-
- // getBranchCheckpoints on master should find the checkpoint from the merged feature branch
- points, err := getBranchCheckpoints(context.Background(), repo, 100)
- if err != nil {
- t.Fatalf("getBranchCheckpoints error: %v", err)
- }
-
- // Should find at least the checkpoint from the merged feature branch
- var found bool
- for _, p := range points {
- if p.CheckpointID == cpID {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("expected to find checkpoint %s from merged feature branch on default branch, got %d points: %v", cpID, len(points), points)
- }
-}
-
-func TestGetBranchCheckpoints_ReadsPromptFromCommittedCheckpoint(t *testing.T) {
- // Verifies that getBranchCheckpoints populates RewindPoint.SessionPrompt
- // from prompt.txt on trace/checkpoints/v1 (committed checkpoint) without
- // needing to read/parse the full transcript.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create a checkpoint ID and write committed checkpoint with prompt data
- cpID, err := id.NewCheckpointID("aabb11223344")
- if err != nil {
- t.Fatalf("failed to create checkpoint ID: %v", err)
- }
-
- expectedPrompt := "Refactor the authentication module to use JWT tokens"
- store := checkpoint.NewGitStore(repo)
- if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "2026-02-27-test-session",
- Strategy: "manual-commit",
- FilesTouched: []string{"auth.go"},
- Prompts: []string{expectedPrompt},
- }); err != nil {
- t.Fatalf("WriteCommitted() error = %v", err)
- }
-
- // Create a user commit with the Trace-Checkpoint trailer
- if err := os.WriteFile(testFile, []byte("updated with auth changes"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitMsg := trailers.FormatCheckpoint("Refactor auth module", cpID)
- _, err = w.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create commit with checkpoint trailer: %v", err)
- }
-
- // Call getBranchCheckpoints and verify prompt is populated
- points, err := getBranchCheckpoints(context.Background(), repo, 10)
- if err != nil {
- t.Fatalf("getBranchCheckpoints() error = %v", err)
- }
-
- var foundCommitted bool
- for _, p := range points {
- if p.CheckpointID == cpID {
- foundCommitted = true
- if !p.IsLogsOnly {
- t.Error("expected committed checkpoint to have IsLogsOnly=true")
- }
- if p.SessionPrompt != expectedPrompt {
- t.Errorf("expected SessionPrompt = %q, got %q", expectedPrompt, p.SessionPrompt)
- }
- break
- }
- }
-
- if !foundCommitted {
- t.Errorf("expected to find committed checkpoint %s, got %d points", cpID, len(points))
- }
-}
-
-func TestGetBranchCheckpoints_V2OnlyCheckpointDiscoverable(t *testing.T) {
- // When the v1 metadata branch doesn't exist but v2 has the checkpoint,
- // getBranchCheckpoints should still find committed checkpoints.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("initial"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Enable v2 via settings.
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- cpID := id.MustCheckpointID("dd11ee22ff33")
- expectedPrompt := "Create the v2-only checkpoint test file"
-
- // Write checkpoint ONLY to v2 store.
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- require.NoError(t, v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-v2-only",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
- Prompts: []string{expectedPrompt},
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // Create a user commit with the Trace-Checkpoint trailer.
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("updated"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- commitMsg := trailers.FormatCheckpoint("Create v2 test file", cpID)
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Verify no v1 metadata branch exists.
- _, v1Err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.Error(t, v1Err, "v1 metadata branch should not exist")
-
- // getBranchCheckpoints should find the v2-only checkpoint.
- points, err := getBranchCheckpoints(context.Background(), repo, 10)
- require.NoError(t, err)
-
- var found bool
- for _, p := range points {
- if p.CheckpointID == cpID {
- found = true
- require.Equal(t, expectedPrompt, p.SessionPrompt,
- "prompt should be read from v2 /main when v1 is absent")
- break
- }
- }
- require.True(t, found, "v2-only checkpoint should be discoverable in branch listing")
-}
-
-func TestGetBranchCheckpoints_V2PromptFallbackWhenV1Deleted(t *testing.T) {
- // When v2 is preferred and v1 metadata branch is deleted after dual-write,
- // prompts should still be readable from v2 /main.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("initial"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- _, err = wt.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(tmpDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`),
- 0o644,
- ))
-
- cpID := id.MustCheckpointID("aa11bb22cc33")
- expectedPrompt := "Dual-write prompt visible after v1 deletion"
-
- // Dual-write: checkpoint in both v1 and v2.
- v1Store := checkpoint.NewGitStore(repo)
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- require.NoError(t, v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
- Prompts: []string{expectedPrompt},
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
- require.NoError(t, v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-dual",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
- Prompts: []string{expectedPrompt},
- AuthorName: "Test",
- AuthorEmail: "test@example.com",
- }))
-
- // Create user commit with checkpoint trailer.
- require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("updated"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- commitMsg := trailers.FormatCheckpoint("Dual-write commit", cpID)
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Delete the v1 metadata branch to simulate it being unavailable.
- require.NoError(t, repo.Storer.RemoveReference(plumbing.NewBranchReferenceName(paths.MetadataBranchName)))
-
- // getBranchCheckpoints should still find the checkpoint and read prompt from v2.
- points, err := getBranchCheckpoints(context.Background(), repo, 10)
- require.NoError(t, err)
-
- var found bool
- for _, p := range points {
- if p.CheckpointID == cpID {
- found = true
- require.Equal(t, expectedPrompt, p.SessionPrompt,
- "prompt should be read from v2 /main after v1 deletion")
- break
- }
- }
- require.True(t, found, "checkpoint should be discoverable after v1 branch deletion")
-}
-
-func TestResolvePromptTree_PrefersV2WhenEnabled(t *testing.T) {
- t.Parallel()
-
- v1 := &object.Tree{}
- v2 := &object.Tree{}
-
- require.Same(t, v2, resolvePromptTree(v1, v2, true), "should prefer v2 when enabled")
- require.Same(t, v1, resolvePromptTree(v1, v2, false), "should prefer v1 when v2 disabled")
- require.Same(t, v1, resolvePromptTree(v1, nil, true), "should fall back to v1 when v2 is nil")
- require.Same(t, v2, resolvePromptTree(nil, v2, false), "should use v2 as last resort when v1 is nil")
- require.Nil(t, resolvePromptTree(nil, nil, true), "should return nil when both are nil")
-}
-
-func TestHasAnyChanges_FirstCommitReturnsTrue(t *testing.T) {
- // First commit (no parent) should always return true
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- commitHash, err := w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- if !hasAnyChanges(commit) {
- t.Error("hasAnyChanges() should return true for first commit (no parent)")
- }
-}
-
-func TestHasAnyChanges_MetadataOnlyChangeReturnsTrue(t *testing.T) {
- // Unlike hasCodeChanges, hasAnyChanges uses tree hash comparison and
- // does not filter out .trace/ metadata files. A metadata-only change
- // should return true because the tree hash differs from the parent's.
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- testutil.InitRepo(t, tmpDir)
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- _, err = w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create second commit with only .trace/ metadata changes
- metadataDir := filepath.Join(tmpDir, ".trace", "metadata", "session-123")
- if err := os.MkdirAll(metadataDir, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil {
- t.Fatalf("failed to write metadata file: %v", err)
- }
- if _, err := w.Add(".trace"); err != nil {
- t.Fatalf("failed to add .trace: %v", err)
- }
- commitHash, err := w.Commit("metadata only commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create second commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit object: %v", err)
- }
-
- // hasAnyChanges compares tree hashes, so metadata-only changes DO count
- // (unlike hasCodeChanges which filters .trace/ files)
- if !hasAnyChanges(commit) {
- t.Error("hasAnyChanges() should return true for metadata-only changes (tree hash differs)")
- }
-}
-
-func TestHasAnyChanges_NoOpTreeChangeReturnsFalse(t *testing.T) {
- // When a commit has the same tree hash as its parent (no-op commit),
- // hasAnyChanges should return false
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- repo, err := git.PlainInit(tmpDir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- w, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create first commit
- testFile := filepath.Join(tmpDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := w.Add("test.txt"); err != nil {
- t.Fatalf("failed to add test file: %v", err)
- }
- firstHash, err := w.Commit("first commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to create first commit: %v", err)
- }
-
- // Create a second commit with the exact same tree (allow-empty equivalent)
- firstCommit, err := repo.CommitObject(firstHash)
- if err != nil {
- t.Fatalf("failed to get first commit: %v", err)
- }
-
- sig := object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now(),
- }
- emptyCommit := object.Commit{
- Author: sig,
- Committer: sig,
- Message: "no-op commit with same tree",
- TreeHash: firstCommit.TreeHash,
- ParentHashes: []plumbing.Hash{firstHash},
- }
- obj := repo.Storer.NewEncodedObject()
- if err := emptyCommit.Encode(obj); err != nil {
- t.Fatalf("failed to encode commit: %v", err)
- }
- secondHash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- t.Fatalf("failed to store commit: %v", err)
- }
-
- secondCommit, err := repo.CommitObject(secondHash)
- if err != nil {
- t.Fatalf("failed to get second commit: %v", err)
- }
-
- // Same tree hash as parent → no changes
- if hasAnyChanges(secondCommit) {
- t.Error("hasAnyChanges() should return false when tree hash matches parent (no-op commit)")
- }
-}
-
-// createCommitWithTree creates a commit with a specific tree and parent hashes.
-func createCommitWithTree(t *testing.T, repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) plumbing.Hash {
- t.Helper()
- sig := object.Signature{
- Name: "Test",
- Email: "test@example.com",
- When: time.Now(),
- }
- commit := object.Commit{
- Author: sig,
- Committer: sig,
- Message: message,
- TreeHash: treeHash,
- ParentHashes: parents,
- }
- obj := repo.Storer.NewEncodedObject()
- if err := commit.Encode(obj); err != nil {
- t.Fatalf("failed to encode commit: %v", err)
- }
- hash, err := repo.Storer.SetEncodedObject(obj)
- if err != nil {
- t.Fatalf("failed to store commit: %v", err)
- }
- return hash
-}
-
-func TestExtractIntent_PrefersScopedPrompt(t *testing.T) {
- t.Parallel()
- got := extractIntent([]string{"add explain --generate flag", "later prompt"}, "fallback prompt\nline2")
- want := "add explain --generate flag"
- if got != want {
- t.Errorf("extractIntent scoped\n got: %q\nwant: %q", got, want)
- }
-}
-
-func TestExtractIntent_FallsBackToFirstLineOfContent(t *testing.T) {
- t.Parallel()
- got := extractIntent(nil, "first content line\nsecond line")
- want := "first content line"
- if got != want {
- t.Errorf("extractIntent fallback\n got: %q\nwant: %q", got, want)
- }
-}
-
-func TestExtractIntent_EmptyReturnsEmpty(t *testing.T) {
- t.Parallel()
- if got := extractIntent(nil, ""); got != "" {
- t.Errorf("extractIntent empty: got %q want empty", got)
- }
- if got := extractIntent([]string{""}, ""); got != "" {
- t.Errorf("extractIntent empty-string-prompt: got %q want empty", got)
- }
-}
-
-func TestExtractIntent_TruncatesLongPrompts(t *testing.T) {
- t.Parallel()
- long := strings.Repeat("a", 500)
- got := extractIntent([]string{long}, "")
- if len(got) >= len(long) {
- t.Errorf("expected truncation; got %d chars", len(got))
- }
-}
-
-func TestBuildNoSummaryMarkdown_IntentAndAffordance(t *testing.T) {
- t.Parallel()
- got := buildNoSummaryMarkdown("add explain --generate flag", nil, "Run `trace explain --generate abc`.")
- if !strings.Contains(got, "## Intent\n\nadd explain --generate flag\n") {
- t.Fatalf("missing intent section:\n%s", got)
- }
- // escapeSummaryText replaces every backtick with U+2018 (‘), so both
- // backticks in "Run `trace explain --generate abc`." map to ‘.
- if !strings.Contains(got, "## Summary\n\n*Run ‘trace explain --generate abc‘.*\n") {
- t.Fatalf("missing italic summary affordance:\n%s", got)
- }
- if strings.Contains(got, "## Files") {
- t.Fatalf("did not expect Files when files=nil:\n%s", got)
- }
-}
-
-func TestBuildNoSummaryMarkdown_RendersFilesWhenProvided(t *testing.T) {
- t.Parallel()
- got := buildNoSummaryMarkdown("intent", []string{"a.go", "b.go"}, "hint")
- if !strings.Contains(got, "## Files (2)\n\n- `a.go`\n- `b.go`\n") {
- t.Fatalf("expected Files section with count and list:\n%s", got)
- }
-}
-
-func TestBuildNoSummaryMarkdown_EmptyIntentShowsPlaceholder(t *testing.T) {
- t.Parallel()
- got := buildNoSummaryMarkdown("", nil, "hint")
- if !strings.Contains(got, "## Intent\n\n*(no prompt recorded)*\n") {
- t.Fatalf("expected italic placeholder:\n%s", got)
- }
-}
-
-func TestRenderExplainBody_NoColorReturnsRawMarkdown(t *testing.T) {
- t.Parallel()
- var buf bytes.Buffer // not a TTY → shouldUseColor false
- got := renderExplainBody(&buf, "## Intent\n\nfoo\n")
- if got != "## Intent\n\nfoo\n" {
- t.Errorf("expected raw markdown when no color\n got: %q", got)
- }
-}
diff --git a/cli/integration_test/agent_2_test.go b/cli/integration_test/agent_2_test.go
new file mode 100644
index 0000000..b48ca29
--- /dev/null
+++ b/cli/integration_test/agent_2_test.go
@@ -0,0 +1,654 @@
+//go:build integration
+
+package integration
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid"
+ _ "github.com/GrayCodeAI/trace/cli/agent/opencode" // Register OpenCode agent
+)
+
+// TestFactoryAIDroidAgentDetection verifies Factory AI Droid agent detection.
+// Not parallel - contains subtests that use os.Chdir which is process-global.
+func TestFactoryAIDroidAgentDetection(t *testing.T) {
+ t.Run("agent is registered", func(t *testing.T) {
+ t.Parallel()
+
+ agents := agent.List()
+ found := false
+ for _, name := range agents {
+ if name == "factoryai-droid" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("agent.List() = %v, want to contain 'factoryai-droid'", agents)
+ }
+ })
+
+ t.Run("detects presence when .factory exists", func(t *testing.T) {
+ // Not parallel - uses os.Chdir which is process-global
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ // Create .factory directory
+ factoryDir := filepath.Join(env.RepoDir, ".factory")
+ if err := os.MkdirAll(factoryDir, 0o755); err != nil {
+ t.Fatalf("failed to create .factory dir: %v", err)
+ }
+
+ // Change to repo dir for detection
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, err := agent.Get("factoryai-droid")
+ if err != nil {
+ t.Fatalf("Get(factoryai-droid) error = %v", err)
+ }
+
+ ctx := context.Background()
+ present, err := ag.DetectPresence(ctx)
+ if err != nil {
+ t.Fatalf("DetectPresence() error = %v", err)
+ }
+ if !present {
+ t.Error("DetectPresence() = false, want true when .factory exists")
+ }
+ })
+}
+
+// TestFactoryAIDroidHookInstallation verifies hook installation via Factory AI Droid agent interface.
+// Note: These tests cannot run in parallel because they use os.Chdir which affects the trace process.
+func TestFactoryAIDroidHookInstallation(t *testing.T) {
+ // Not parallel - tests use os.Chdir which is process-global
+
+ t.Run("installs all required hooks", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ // Change to repo dir
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, err := agent.Get("factoryai-droid")
+ if err != nil {
+ t.Fatalf("Get(factoryai-droid) error = %v", err)
+ }
+
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ t.Fatal("factoryai-droid agent does not implement HookSupport")
+ }
+
+ ctx := context.Background()
+ count, err := hookAgent.InstallHooks(ctx, false, false)
+ if err != nil {
+ t.Fatalf("InstallHooks() error = %v", err)
+ }
+
+ // Should install 8 hooks: SessionStart (session-start + user-prompt-submit), SessionEnd,
+ // Stop, UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact
+ if count != 8 {
+ t.Errorf("InstallHooks() count = %d, want 8", count)
+ }
+
+ // Verify hooks are installed
+ if !hookAgent.AreHooksInstalled(ctx) {
+ t.Error("AreHooksInstalled() = false after InstallHooks()")
+ }
+
+ // Verify settings.json was created
+ settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
+ if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
+ t.Error("settings.json was not created")
+ }
+
+ // Verify hooks structure in settings.json
+ data, err := os.ReadFile(settingsPath)
+ if err != nil {
+ t.Fatalf("failed to read settings.json: %v", err)
+ }
+ content := string(data)
+
+ // Verify all hook types are present
+ if !strings.Contains(content, "SessionStart") {
+ t.Error("settings.json should contain SessionStart hook")
+ }
+ if !strings.Contains(content, "SessionEnd") {
+ t.Error("settings.json should contain SessionEnd hook")
+ }
+ if !strings.Contains(content, "Stop") {
+ t.Error("settings.json should contain Stop hook")
+ }
+ if !strings.Contains(content, "UserPromptSubmit") {
+ t.Error("settings.json should contain UserPromptSubmit hook")
+ }
+ if !strings.Contains(content, "PreToolUse") {
+ t.Error("settings.json should contain PreToolUse hook")
+ }
+ if !strings.Contains(content, "PostToolUse") {
+ t.Error("settings.json should contain PostToolUse hook")
+ }
+ if !strings.Contains(content, "PreCompact") {
+ t.Error("settings.json should contain PreCompact hook")
+ }
+
+ // Verify permissions.deny contains metadata deny rule
+ if !strings.Contains(content, "Read(./.trace/metadata/**)") {
+ t.Error("settings.json should contain permissions.deny rule for .trace/metadata/**")
+ }
+ })
+
+ t.Run("idempotent - second install returns 0", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, _ := agent.Get("factoryai-droid")
+ hookAgent, _ := agent.AsHookSupport(ag)
+
+ ctx := context.Background()
+ // First install
+ _, err := hookAgent.InstallHooks(ctx, false, false)
+ if err != nil {
+ t.Fatalf("first InstallHooks() error = %v", err)
+ }
+
+ // Second install should be idempotent
+ count, err := hookAgent.InstallHooks(ctx, false, false)
+ if err != nil {
+ t.Fatalf("second InstallHooks() error = %v", err)
+ }
+ if count != 0 {
+ t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count)
+ }
+ })
+
+ t.Run("localDev mode uses go run", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, _ := agent.Get("factoryai-droid")
+ hookAgent, _ := agent.AsHookSupport(ag)
+
+ ctx := context.Background()
+ _, err := hookAgent.InstallHooks(ctx, true, false) // localDev = true
+ if err != nil {
+ t.Fatalf("InstallHooks(localDev=true) error = %v", err)
+ }
+
+ // Read settings and verify commands use "go run"
+ settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
+ data, err := os.ReadFile(settingsPath)
+ if err != nil {
+ t.Fatalf("failed to read settings.json: %v", err)
+ }
+
+ content := string(data)
+ if !strings.Contains(content, "go run") {
+ t.Error("localDev hooks should use 'go run', but settings.json doesn't contain it")
+ }
+ if !strings.Contains(content, "$(git rev-parse --show-toplevel)") {
+ t.Error("localDev hooks should use '$(git rev-parse --show-toplevel)', but settings.json doesn't contain it")
+ }
+ })
+
+ t.Run("production mode uses trace binary", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, _ := agent.Get("factoryai-droid")
+ hookAgent, _ := agent.AsHookSupport(ag)
+
+ ctx := context.Background()
+ _, err := hookAgent.InstallHooks(ctx, false, false) // localDev = false
+ if err != nil {
+ t.Fatalf("InstallHooks(localDev=false) error = %v", err)
+ }
+
+ // Read settings and verify commands use "trace" binary
+ settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
+ data, err := os.ReadFile(settingsPath)
+ if err != nil {
+ t.Fatalf("failed to read settings.json: %v", err)
+ }
+
+ content := string(data)
+ if !strings.Contains(content, "trace hooks factoryai-droid") {
+ t.Error("production hooks should use 'trace hooks factoryai-droid', but settings.json doesn't contain it")
+ }
+ })
+
+ t.Run("force flag reinstalls hooks", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, _ := agent.Get("factoryai-droid")
+ hookAgent, _ := agent.AsHookSupport(ag)
+
+ ctx := context.Background()
+ // First install
+ _, err := hookAgent.InstallHooks(ctx, false, false)
+ if err != nil {
+ t.Fatalf("first InstallHooks() error = %v", err)
+ }
+
+ // Force reinstall should return count > 0
+ count, err := hookAgent.InstallHooks(ctx, false, true) // force = true
+ if err != nil {
+ t.Fatalf("force InstallHooks() error = %v", err)
+ }
+ if count != 8 {
+ t.Errorf("force InstallHooks() count = %d, want 8", count)
+ }
+ })
+}
+
+// TestFactoryAIDroidSessionMethods verifies ReadSession, WriteSession, and GetSessionDir.
+func TestFactoryAIDroidSessionMethods(t *testing.T) {
+ t.Parallel()
+
+ t.Run("ReadSession reads and parses transcript", func(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ transcriptPath := filepath.Join(tmpDir, "transcript.jsonl")
+ content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
+{"type":"message","id":"msg2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`
+ if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ ag, _ := agent.Get("factoryai-droid")
+ session, err := ag.ReadSession(&agent.HookInput{
+ SessionID: "test",
+ SessionRef: transcriptPath,
+ })
+ if err != nil {
+ t.Fatalf("ReadSession() error = %v", err)
+ }
+ if session.SessionID != "test" {
+ t.Errorf("SessionID = %q, want %q", session.SessionID, "test")
+ }
+ if len(session.NativeData) == 0 {
+ t.Error("NativeData should not be empty")
+ }
+ })
+
+ t.Run("ReadSession errors on missing file", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("factoryai-droid")
+ _, err := ag.ReadSession(&agent.HookInput{
+ SessionID: "test",
+ SessionRef: "/nonexistent/path/transcript.jsonl",
+ })
+ if err == nil {
+ t.Error("ReadSession() should error on missing file")
+ }
+ })
+
+ t.Run("WriteSession round-trips with ReadSession", func(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ originalPath := filepath.Join(tmpDir, "original.jsonl")
+ restoredPath := filepath.Join(tmpDir, "sub", "restored.jsonl")
+
+ content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`
+ if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write original: %v", err)
+ }
+
+ ag, _ := agent.Get("factoryai-droid")
+ session, err := ag.ReadSession(&agent.HookInput{
+ SessionID: "test",
+ SessionRef: originalPath,
+ })
+ if err != nil {
+ t.Fatalf("ReadSession() error = %v", err)
+ }
+
+ session.SessionRef = restoredPath
+ ctx := context.Background()
+ if err := ag.WriteSession(ctx, session); err != nil {
+ t.Fatalf("WriteSession() error = %v", err)
+ }
+
+ restored, err := os.ReadFile(restoredPath)
+ if err != nil {
+ t.Fatalf("failed to read restored: %v", err)
+ }
+ if string(restored) != content {
+ t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content)
+ }
+ })
+
+ t.Run("GetSessionDir returns factory sessions path", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("factoryai-droid")
+ dir, err := ag.GetSessionDir("/Users/test/my-project")
+ if err != nil {
+ t.Fatalf("GetSessionDir() error = %v", err)
+ }
+ if !strings.Contains(dir, filepath.Join(".factory", "sessions")) {
+ t.Errorf("GetSessionDir() = %q, want to contain .factory/sessions", dir)
+ }
+ if !strings.HasSuffix(dir, "-Users-test-my-project") {
+ t.Errorf("GetSessionDir() = %q, want to end with sanitized path", dir)
+ }
+ })
+}
+
+// --- OpenCode Agent Tests ---
+
+// TestOpenCodeAgentDetection verifies OpenCode agent detection and default behavior.
+func TestOpenCodeAgentDetection(t *testing.T) {
+ t.Run("opencode agent is registered", func(t *testing.T) {
+ t.Parallel()
+
+ agents := agent.List()
+ found := false
+ for _, name := range agents {
+ if name == "opencode" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("agent.List() = %v, want to contain 'opencode'", agents)
+ }
+ })
+
+ t.Run("opencode detects presence when .opencode exists", func(t *testing.T) {
+ // Not parallel - uses os.Chdir which is process-global
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ // Create .opencode directory
+ opencodeDir := filepath.Join(env.RepoDir, ".opencode")
+ if err := os.MkdirAll(opencodeDir, 0o755); err != nil {
+ t.Fatalf("failed to create .opencode dir: %v", err)
+ }
+
+ // Change to repo dir for detection
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, err := agent.Get("opencode")
+ if err != nil {
+ t.Fatalf("Get(opencode) error = %v", err)
+ }
+
+ present, err := ag.DetectPresence(context.Background())
+ if err != nil {
+ t.Fatalf("DetectPresence() error = %v", err)
+ }
+ if !present {
+ t.Error("DetectPresence() = false, want true when .opencode exists")
+ }
+ })
+
+ t.Run("opencode detects presence when opencode.json exists", func(t *testing.T) {
+ // Not parallel - uses os.Chdir which is process-global
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ // Create opencode.json config file
+ configPath := filepath.Join(env.RepoDir, "opencode.json")
+ if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("failed to write opencode.json: %v", err)
+ }
+
+ // Change to repo dir for detection
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, err := agent.Get("opencode")
+ if err != nil {
+ t.Fatalf("Get(opencode) error = %v", err)
+ }
+
+ present, err := ag.DetectPresence(context.Background())
+ if err != nil {
+ t.Fatalf("DetectPresence() error = %v", err)
+ }
+ if !present {
+ t.Error("DetectPresence() = false, want true when opencode.json exists")
+ }
+ })
+}
+
+// TestOpenCodeHookInstallation verifies hook installation via OpenCode agent interface.
+// Not parallel - uses os.Chdir which is process-global.
+func TestOpenCodeHookInstallation(t *testing.T) {
+ t.Run("installs plugin file", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, err := agent.Get("opencode")
+ if err != nil {
+ t.Fatalf("Get(opencode) error = %v", err)
+ }
+
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ t.Fatal("opencode agent does not implement HookSupport")
+ }
+
+ count, err := hookAgent.InstallHooks(context.Background(), false, false)
+ if err != nil {
+ t.Fatalf("InstallHooks() error = %v", err)
+ }
+
+ // Should install 1 plugin file
+ if count != 1 {
+ t.Errorf("InstallHooks() count = %d, want 1", count)
+ }
+
+ // Verify hooks are installed
+ if !hookAgent.AreHooksInstalled(context.Background()) {
+ t.Error("AreHooksInstalled() = false after InstallHooks()")
+ }
+
+ // Verify plugin file was created
+ pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "trace.ts")
+ if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
+ t.Error("trace.ts plugin was not created")
+ }
+ })
+
+ t.Run("idempotent - second install returns 0", func(t *testing.T) {
+ // Not parallel - uses os.Chdir
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ oldWd, _ := os.Getwd()
+ if err := os.Chdir(env.RepoDir); err != nil {
+ t.Fatalf("failed to chdir: %v", err)
+ }
+ defer func() { _ = os.Chdir(oldWd) }()
+
+ ag, _ := agent.Get("opencode")
+ hookAgent, _ := agent.AsHookSupport(ag)
+
+ // First install
+ _, err := hookAgent.InstallHooks(context.Background(), false, false)
+ if err != nil {
+ t.Fatalf("first InstallHooks() error = %v", err)
+ }
+
+ // Second install should be idempotent
+ count, err := hookAgent.InstallHooks(context.Background(), false, false)
+ if err != nil {
+ t.Fatalf("second InstallHooks() error = %v", err)
+ }
+ if count != 0 {
+ t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count)
+ }
+ })
+}
+
+// TestOpenCodeSessionOperations verifies ReadSession/WriteSession via OpenCode agent interface.
+func TestOpenCodeSessionOperations(t *testing.T) {
+ t.Parallel()
+
+ t.Run("ReadSession parses export JSON transcript and computes ModifiedFiles", func(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ env.InitRepo()
+
+ // Create an OpenCode export JSON transcript file
+ transcriptPath := filepath.Join(env.RepoDir, "test-transcript.json")
+ transcriptContent := `{
+ "info": {"id": "test-session"},
+ "messages": [
+ {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Fix the bug"}]},
+ {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001, "completed": 1708300005}, "tokens": {"input": 100, "output": 50, "reasoning": 5, "cache": {"read": 3, "write": 10}}}, "parts": [{"type": "text", "text": "I'll fix it."}, {"type": "tool", "tool": "write", "callID": "call-1", "state": {"status": "completed", "input": {"filePath": "main.go"}, "output": "written"}}]},
+ {"info": {"id": "msg-3", "role": "user", "time": {"created": 1708300010}}, "parts": [{"type": "text", "text": "Also fix util.go"}]},
+ {"info": {"id": "msg-4", "role": "assistant", "time": {"created": 1708300011, "completed": 1708300015}, "tokens": {"input": 120, "output": 60, "reasoning": 3, "cache": {"read": 5, "write": 12}}}, "parts": [{"type": "tool", "tool": "edit", "callID": "call-2", "state": {"status": "completed", "input": {"filePath": "util.go"}, "output": "edited"}}]}
+ ]
+ }`
+ if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ ag, _ := agent.Get("opencode")
+ session, err := ag.ReadSession(&agent.HookInput{
+ SessionID: "test-session",
+ SessionRef: transcriptPath,
+ })
+ if err != nil {
+ t.Fatalf("ReadSession() error = %v", err)
+ }
+
+ // Verify session metadata
+ if session.SessionID != "test-session" {
+ t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session")
+ }
+ if session.AgentName != "opencode" {
+ t.Errorf("AgentName = %q, want %q", session.AgentName, "opencode")
+ }
+
+ // Verify NativeData is populated
+ if len(session.NativeData) == 0 {
+ t.Error("NativeData is empty, want transcript content")
+ }
+
+ // Verify ModifiedFiles computed from tool calls
+ if len(session.ModifiedFiles) != 2 {
+ t.Errorf("ModifiedFiles = %v, want 2 files (main.go, util.go)", session.ModifiedFiles)
+ }
+ })
+
+ t.Run("WriteSession validates input", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("opencode")
+
+ if err := ag.WriteSession(context.Background(), nil); err == nil {
+ t.Error("WriteSession(nil) should error")
+ }
+ if err := ag.WriteSession(context.Background(), &agent.AgentSession{}); err == nil {
+ t.Error("WriteSession with empty NativeData should error")
+ }
+ })
+}
+
+// TestOpenCodeHelperMethods verifies OpenCode-specific helper methods.
+func TestOpenCodeHelperMethods(t *testing.T) {
+ t.Parallel()
+
+ t.Run("FormatResumeCommand returns opencode -s", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("opencode")
+ cmd := ag.FormatResumeCommand("abc123")
+
+ if cmd != "opencode -s abc123" {
+ t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode -s abc123")
+ }
+ })
+
+ t.Run("ProtectedDirs includes .opencode", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("opencode")
+ dirs := ag.ProtectedDirs()
+
+ found := false
+ for _, d := range dirs {
+ if d == ".opencode" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("ProtectedDirs() = %v, want to contain '.opencode'", dirs)
+ }
+ })
+
+ t.Run("IsPreview returns true", func(t *testing.T) {
+ t.Parallel()
+
+ ag, _ := agent.Get("opencode")
+ if !ag.IsPreview() {
+ t.Error("IsPreview() = false, want true")
+ }
+ })
+}
diff --git a/cli/integration_test/agent_test.go b/cli/integration_test/agent_test.go
index 830a29d..c396d63 100644
--- a/cli/integration_test/agent_test.go
+++ b/cli/integration_test/agent_test.go
@@ -11,7 +11,6 @@ import (
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/claudecode"
- "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid"
"github.com/GrayCodeAI/trace/cli/agent/geminicli"
_ "github.com/GrayCodeAI/trace/cli/agent/opencode" // Register OpenCode agent
"github.com/GrayCodeAI/trace/cli/transcript"
@@ -791,642 +790,3 @@ func TestGeminiCLIHelperMethods(t *testing.T) {
}
// --- Factory AI Droid Agent Tests ---
-
-// TestFactoryAIDroidAgentDetection verifies Factory AI Droid agent detection.
-// Not parallel - contains subtests that use os.Chdir which is process-global.
-func TestFactoryAIDroidAgentDetection(t *testing.T) {
- t.Run("agent is registered", func(t *testing.T) {
- t.Parallel()
-
- agents := agent.List()
- found := false
- for _, name := range agents {
- if name == "factoryai-droid" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("agent.List() = %v, want to contain 'factoryai-droid'", agents)
- }
- })
-
- t.Run("detects presence when .factory exists", func(t *testing.T) {
- // Not parallel - uses os.Chdir which is process-global
- env := NewTestEnv(t)
- env.InitRepo()
-
- // Create .factory directory
- factoryDir := filepath.Join(env.RepoDir, ".factory")
- if err := os.MkdirAll(factoryDir, 0o755); err != nil {
- t.Fatalf("failed to create .factory dir: %v", err)
- }
-
- // Change to repo dir for detection
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, err := agent.Get("factoryai-droid")
- if err != nil {
- t.Fatalf("Get(factoryai-droid) error = %v", err)
- }
-
- ctx := context.Background()
- present, err := ag.DetectPresence(ctx)
- if err != nil {
- t.Fatalf("DetectPresence() error = %v", err)
- }
- if !present {
- t.Error("DetectPresence() = false, want true when .factory exists")
- }
- })
-}
-
-// TestFactoryAIDroidHookInstallation verifies hook installation via Factory AI Droid agent interface.
-// Note: These tests cannot run in parallel because they use os.Chdir which affects the trace process.
-func TestFactoryAIDroidHookInstallation(t *testing.T) {
- // Not parallel - tests use os.Chdir which is process-global
-
- t.Run("installs all required hooks", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- // Change to repo dir
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, err := agent.Get("factoryai-droid")
- if err != nil {
- t.Fatalf("Get(factoryai-droid) error = %v", err)
- }
-
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- t.Fatal("factoryai-droid agent does not implement HookSupport")
- }
-
- ctx := context.Background()
- count, err := hookAgent.InstallHooks(ctx, false, false)
- if err != nil {
- t.Fatalf("InstallHooks() error = %v", err)
- }
-
- // Should install 8 hooks: SessionStart (session-start + user-prompt-submit), SessionEnd,
- // Stop, UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact
- if count != 8 {
- t.Errorf("InstallHooks() count = %d, want 8", count)
- }
-
- // Verify hooks are installed
- if !hookAgent.AreHooksInstalled(ctx) {
- t.Error("AreHooksInstalled() = false after InstallHooks()")
- }
-
- // Verify settings.json was created
- settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
- if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
- t.Error("settings.json was not created")
- }
-
- // Verify hooks structure in settings.json
- data, err := os.ReadFile(settingsPath)
- if err != nil {
- t.Fatalf("failed to read settings.json: %v", err)
- }
- content := string(data)
-
- // Verify all hook types are present
- if !strings.Contains(content, "SessionStart") {
- t.Error("settings.json should contain SessionStart hook")
- }
- if !strings.Contains(content, "SessionEnd") {
- t.Error("settings.json should contain SessionEnd hook")
- }
- if !strings.Contains(content, "Stop") {
- t.Error("settings.json should contain Stop hook")
- }
- if !strings.Contains(content, "UserPromptSubmit") {
- t.Error("settings.json should contain UserPromptSubmit hook")
- }
- if !strings.Contains(content, "PreToolUse") {
- t.Error("settings.json should contain PreToolUse hook")
- }
- if !strings.Contains(content, "PostToolUse") {
- t.Error("settings.json should contain PostToolUse hook")
- }
- if !strings.Contains(content, "PreCompact") {
- t.Error("settings.json should contain PreCompact hook")
- }
-
- // Verify permissions.deny contains metadata deny rule
- if !strings.Contains(content, "Read(./.trace/metadata/**)") {
- t.Error("settings.json should contain permissions.deny rule for .trace/metadata/**")
- }
- })
-
- t.Run("idempotent - second install returns 0", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, _ := agent.Get("factoryai-droid")
- hookAgent, _ := agent.AsHookSupport(ag)
-
- ctx := context.Background()
- // First install
- _, err := hookAgent.InstallHooks(ctx, false, false)
- if err != nil {
- t.Fatalf("first InstallHooks() error = %v", err)
- }
-
- // Second install should be idempotent
- count, err := hookAgent.InstallHooks(ctx, false, false)
- if err != nil {
- t.Fatalf("second InstallHooks() error = %v", err)
- }
- if count != 0 {
- t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count)
- }
- })
-
- t.Run("localDev mode uses go run", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, _ := agent.Get("factoryai-droid")
- hookAgent, _ := agent.AsHookSupport(ag)
-
- ctx := context.Background()
- _, err := hookAgent.InstallHooks(ctx, true, false) // localDev = true
- if err != nil {
- t.Fatalf("InstallHooks(localDev=true) error = %v", err)
- }
-
- // Read settings and verify commands use "go run"
- settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
- data, err := os.ReadFile(settingsPath)
- if err != nil {
- t.Fatalf("failed to read settings.json: %v", err)
- }
-
- content := string(data)
- if !strings.Contains(content, "go run") {
- t.Error("localDev hooks should use 'go run', but settings.json doesn't contain it")
- }
- if !strings.Contains(content, "$(git rev-parse --show-toplevel)") {
- t.Error("localDev hooks should use '$(git rev-parse --show-toplevel)', but settings.json doesn't contain it")
- }
- })
-
- t.Run("production mode uses trace binary", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, _ := agent.Get("factoryai-droid")
- hookAgent, _ := agent.AsHookSupport(ag)
-
- ctx := context.Background()
- _, err := hookAgent.InstallHooks(ctx, false, false) // localDev = false
- if err != nil {
- t.Fatalf("InstallHooks(localDev=false) error = %v", err)
- }
-
- // Read settings and verify commands use "trace" binary
- settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName)
- data, err := os.ReadFile(settingsPath)
- if err != nil {
- t.Fatalf("failed to read settings.json: %v", err)
- }
-
- content := string(data)
- if !strings.Contains(content, "trace hooks factoryai-droid") {
- t.Error("production hooks should use 'trace hooks factoryai-droid', but settings.json doesn't contain it")
- }
- })
-
- t.Run("force flag reinstalls hooks", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, _ := agent.Get("factoryai-droid")
- hookAgent, _ := agent.AsHookSupport(ag)
-
- ctx := context.Background()
- // First install
- _, err := hookAgent.InstallHooks(ctx, false, false)
- if err != nil {
- t.Fatalf("first InstallHooks() error = %v", err)
- }
-
- // Force reinstall should return count > 0
- count, err := hookAgent.InstallHooks(ctx, false, true) // force = true
- if err != nil {
- t.Fatalf("force InstallHooks() error = %v", err)
- }
- if count != 8 {
- t.Errorf("force InstallHooks() count = %d, want 8", count)
- }
- })
-}
-
-// TestFactoryAIDroidSessionMethods verifies ReadSession, WriteSession, and GetSessionDir.
-func TestFactoryAIDroidSessionMethods(t *testing.T) {
- t.Parallel()
-
- t.Run("ReadSession reads and parses transcript", func(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- transcriptPath := filepath.Join(tmpDir, "transcript.jsonl")
- content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
-{"type":"message","id":"msg2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`
- if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- ag, _ := agent.Get("factoryai-droid")
- session, err := ag.ReadSession(&agent.HookInput{
- SessionID: "test",
- SessionRef: transcriptPath,
- })
- if err != nil {
- t.Fatalf("ReadSession() error = %v", err)
- }
- if session.SessionID != "test" {
- t.Errorf("SessionID = %q, want %q", session.SessionID, "test")
- }
- if len(session.NativeData) == 0 {
- t.Error("NativeData should not be empty")
- }
- })
-
- t.Run("ReadSession errors on missing file", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("factoryai-droid")
- _, err := ag.ReadSession(&agent.HookInput{
- SessionID: "test",
- SessionRef: "/nonexistent/path/transcript.jsonl",
- })
- if err == nil {
- t.Error("ReadSession() should error on missing file")
- }
- })
-
- t.Run("WriteSession round-trips with ReadSession", func(t *testing.T) {
- t.Parallel()
-
- tmpDir := t.TempDir()
- originalPath := filepath.Join(tmpDir, "original.jsonl")
- restoredPath := filepath.Join(tmpDir, "sub", "restored.jsonl")
-
- content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`
- if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil {
- t.Fatalf("failed to write original: %v", err)
- }
-
- ag, _ := agent.Get("factoryai-droid")
- session, err := ag.ReadSession(&agent.HookInput{
- SessionID: "test",
- SessionRef: originalPath,
- })
- if err != nil {
- t.Fatalf("ReadSession() error = %v", err)
- }
-
- session.SessionRef = restoredPath
- ctx := context.Background()
- if err := ag.WriteSession(ctx, session); err != nil {
- t.Fatalf("WriteSession() error = %v", err)
- }
-
- restored, err := os.ReadFile(restoredPath)
- if err != nil {
- t.Fatalf("failed to read restored: %v", err)
- }
- if string(restored) != content {
- t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content)
- }
- })
-
- t.Run("GetSessionDir returns factory sessions path", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("factoryai-droid")
- dir, err := ag.GetSessionDir("/Users/test/my-project")
- if err != nil {
- t.Fatalf("GetSessionDir() error = %v", err)
- }
- if !strings.Contains(dir, filepath.Join(".factory", "sessions")) {
- t.Errorf("GetSessionDir() = %q, want to contain .factory/sessions", dir)
- }
- if !strings.HasSuffix(dir, "-Users-test-my-project") {
- t.Errorf("GetSessionDir() = %q, want to end with sanitized path", dir)
- }
- })
-}
-
-// --- OpenCode Agent Tests ---
-
-// TestOpenCodeAgentDetection verifies OpenCode agent detection and default behavior.
-func TestOpenCodeAgentDetection(t *testing.T) {
- t.Run("opencode agent is registered", func(t *testing.T) {
- t.Parallel()
-
- agents := agent.List()
- found := false
- for _, name := range agents {
- if name == "opencode" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("agent.List() = %v, want to contain 'opencode'", agents)
- }
- })
-
- t.Run("opencode detects presence when .opencode exists", func(t *testing.T) {
- // Not parallel - uses os.Chdir which is process-global
- env := NewTestEnv(t)
- env.InitRepo()
-
- // Create .opencode directory
- opencodeDir := filepath.Join(env.RepoDir, ".opencode")
- if err := os.MkdirAll(opencodeDir, 0o755); err != nil {
- t.Fatalf("failed to create .opencode dir: %v", err)
- }
-
- // Change to repo dir for detection
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, err := agent.Get("opencode")
- if err != nil {
- t.Fatalf("Get(opencode) error = %v", err)
- }
-
- present, err := ag.DetectPresence(context.Background())
- if err != nil {
- t.Fatalf("DetectPresence() error = %v", err)
- }
- if !present {
- t.Error("DetectPresence() = false, want true when .opencode exists")
- }
- })
-
- t.Run("opencode detects presence when opencode.json exists", func(t *testing.T) {
- // Not parallel - uses os.Chdir which is process-global
- env := NewTestEnv(t)
- env.InitRepo()
-
- // Create opencode.json config file
- configPath := filepath.Join(env.RepoDir, "opencode.json")
- if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
- t.Fatalf("failed to write opencode.json: %v", err)
- }
-
- // Change to repo dir for detection
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, err := agent.Get("opencode")
- if err != nil {
- t.Fatalf("Get(opencode) error = %v", err)
- }
-
- present, err := ag.DetectPresence(context.Background())
- if err != nil {
- t.Fatalf("DetectPresence() error = %v", err)
- }
- if !present {
- t.Error("DetectPresence() = false, want true when opencode.json exists")
- }
- })
-}
-
-// TestOpenCodeHookInstallation verifies hook installation via OpenCode agent interface.
-// Not parallel - uses os.Chdir which is process-global.
-func TestOpenCodeHookInstallation(t *testing.T) {
- t.Run("installs plugin file", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, err := agent.Get("opencode")
- if err != nil {
- t.Fatalf("Get(opencode) error = %v", err)
- }
-
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- t.Fatal("opencode agent does not implement HookSupport")
- }
-
- count, err := hookAgent.InstallHooks(context.Background(), false, false)
- if err != nil {
- t.Fatalf("InstallHooks() error = %v", err)
- }
-
- // Should install 1 plugin file
- if count != 1 {
- t.Errorf("InstallHooks() count = %d, want 1", count)
- }
-
- // Verify hooks are installed
- if !hookAgent.AreHooksInstalled(context.Background()) {
- t.Error("AreHooksInstalled() = false after InstallHooks()")
- }
-
- // Verify plugin file was created
- pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "trace.ts")
- if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
- t.Error("trace.ts plugin was not created")
- }
- })
-
- t.Run("idempotent - second install returns 0", func(t *testing.T) {
- // Not parallel - uses os.Chdir
- env := NewTestEnv(t)
- env.InitRepo()
-
- oldWd, _ := os.Getwd()
- if err := os.Chdir(env.RepoDir); err != nil {
- t.Fatalf("failed to chdir: %v", err)
- }
- defer func() { _ = os.Chdir(oldWd) }()
-
- ag, _ := agent.Get("opencode")
- hookAgent, _ := agent.AsHookSupport(ag)
-
- // First install
- _, err := hookAgent.InstallHooks(context.Background(), false, false)
- if err != nil {
- t.Fatalf("first InstallHooks() error = %v", err)
- }
-
- // Second install should be idempotent
- count, err := hookAgent.InstallHooks(context.Background(), false, false)
- if err != nil {
- t.Fatalf("second InstallHooks() error = %v", err)
- }
- if count != 0 {
- t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count)
- }
- })
-}
-
-// TestOpenCodeSessionOperations verifies ReadSession/WriteSession via OpenCode agent interface.
-func TestOpenCodeSessionOperations(t *testing.T) {
- t.Parallel()
-
- t.Run("ReadSession parses export JSON transcript and computes ModifiedFiles", func(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- env.InitRepo()
-
- // Create an OpenCode export JSON transcript file
- transcriptPath := filepath.Join(env.RepoDir, "test-transcript.json")
- transcriptContent := `{
- "info": {"id": "test-session"},
- "messages": [
- {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Fix the bug"}]},
- {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001, "completed": 1708300005}, "tokens": {"input": 100, "output": 50, "reasoning": 5, "cache": {"read": 3, "write": 10}}}, "parts": [{"type": "text", "text": "I'll fix it."}, {"type": "tool", "tool": "write", "callID": "call-1", "state": {"status": "completed", "input": {"filePath": "main.go"}, "output": "written"}}]},
- {"info": {"id": "msg-3", "role": "user", "time": {"created": 1708300010}}, "parts": [{"type": "text", "text": "Also fix util.go"}]},
- {"info": {"id": "msg-4", "role": "assistant", "time": {"created": 1708300011, "completed": 1708300015}, "tokens": {"input": 120, "output": 60, "reasoning": 3, "cache": {"read": 5, "write": 12}}}, "parts": [{"type": "tool", "tool": "edit", "callID": "call-2", "state": {"status": "completed", "input": {"filePath": "util.go"}, "output": "edited"}}]}
- ]
- }`
- if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- ag, _ := agent.Get("opencode")
- session, err := ag.ReadSession(&agent.HookInput{
- SessionID: "test-session",
- SessionRef: transcriptPath,
- })
- if err != nil {
- t.Fatalf("ReadSession() error = %v", err)
- }
-
- // Verify session metadata
- if session.SessionID != "test-session" {
- t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session")
- }
- if session.AgentName != "opencode" {
- t.Errorf("AgentName = %q, want %q", session.AgentName, "opencode")
- }
-
- // Verify NativeData is populated
- if len(session.NativeData) == 0 {
- t.Error("NativeData is empty, want transcript content")
- }
-
- // Verify ModifiedFiles computed from tool calls
- if len(session.ModifiedFiles) != 2 {
- t.Errorf("ModifiedFiles = %v, want 2 files (main.go, util.go)", session.ModifiedFiles)
- }
- })
-
- t.Run("WriteSession validates input", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("opencode")
-
- if err := ag.WriteSession(context.Background(), nil); err == nil {
- t.Error("WriteSession(nil) should error")
- }
- if err := ag.WriteSession(context.Background(), &agent.AgentSession{}); err == nil {
- t.Error("WriteSession with empty NativeData should error")
- }
- })
-}
-
-// TestOpenCodeHelperMethods verifies OpenCode-specific helper methods.
-func TestOpenCodeHelperMethods(t *testing.T) {
- t.Parallel()
-
- t.Run("FormatResumeCommand returns opencode -s", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("opencode")
- cmd := ag.FormatResumeCommand("abc123")
-
- if cmd != "opencode -s abc123" {
- t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode -s abc123")
- }
- })
-
- t.Run("ProtectedDirs includes .opencode", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("opencode")
- dirs := ag.ProtectedDirs()
-
- found := false
- for _, d := range dirs {
- if d == ".opencode" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("ProtectedDirs() = %v, want to contain '.opencode'", dirs)
- }
- })
-
- t.Run("IsPreview returns true", func(t *testing.T) {
- t.Parallel()
-
- ag, _ := agent.Get("opencode")
- if !ag.IsPreview() {
- t.Error("IsPreview() = false, want true")
- }
- })
-}
diff --git a/cli/integration_test/deferred_finalization_2_test.go b/cli/integration_test/deferred_finalization_2_test.go
new file mode 100644
index 0000000..a070b7d
--- /dev/null
+++ b/cli/integration_test/deferred_finalization_2_test.go
@@ -0,0 +1,575 @@
+//go:build integration
+
+package integration
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+)
+
+// TestShadow_SessionDepleted_ManualEditNoCheckpoint tests that once all session
+// files are committed, subsequent manual edits (even to previously committed files)
+// do NOT get checkpoint trailers.
+//
+// Flow:
+// 1. Agent creates files A, B, C, then stops (IDLE)
+// 2. User commits files A and B → checkpoint #1
+// 3. User commits file C → checkpoint #2 (carry-forward if implemented, or just C)
+// 4. Session is now "depleted" (all FilesTouched committed)
+// 5. User manually edits file A and commits → NO checkpoint (session exhausted)
+func TestShadow_SessionDepleted_ManualEditNoCheckpoint(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ sess := env.NewSession()
+
+ // Start session
+ if err := env.SimulateUserPromptSubmitWithPrompt(sess.ID, "Create files A, B, and C"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // Create 3 files through session
+ env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
+ env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
+ env.WriteFile("fileC.go", "package main\n\nfunc C() {}\n")
+ sess.CreateTranscript("Create files A, B, and C", []FileChange{
+ {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
+ {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
+ {Path: "fileC.go", Content: "package main\n\nfunc C() {}\n"},
+ })
+
+ // Stop session (becomes IDLE)
+ if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // First commit: files A and B
+ env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go")
+ firstCommitHash := env.GetHeadHash()
+ firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
+ if firstCheckpointID == "" {
+ t.Fatal("First commit should have checkpoint trailer (files overlap with session)")
+ }
+ t.Logf("First checkpoint ID: %s", firstCheckpointID)
+
+ // Second commit: file C
+ env.GitCommitWithShadowHooks("Add file C", "fileC.go")
+ secondCommitHash := env.GetHeadHash()
+ secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
+ // Note: Whether this gets a checkpoint depends on carry-forward implementation
+ // for IDLE sessions. Log either way.
+ if secondCheckpointID != "" {
+ t.Logf("Second checkpoint ID: %s (carry-forward active for IDLE)", secondCheckpointID)
+ } else {
+ t.Log("Second commit has no checkpoint (IDLE sessions don't carry forward)")
+ }
+
+ // Verify session state - FilesTouched should be empty or session ended
+ state, err := env.GetSessionState(sess.ID)
+ if err != nil {
+ // Session may have been cleaned up, which is fine
+ t.Logf("Session state not found (may have been cleaned up): %v", err)
+ } else {
+ t.Logf("Session state after all commits: Phase=%s, FilesTouched=%v",
+ state.Phase, state.FilesTouched)
+ }
+
+ // Now manually edit file A (which was already committed as part of session)
+ env.WriteFile("fileA.go", "package main\n\n// Manual edit by user\nfunc A() { return }\n")
+
+ // Commit the manual edit - should NOT get checkpoint
+ env.GitCommitWithShadowHooks("Manual edit to file A", "fileA.go")
+ thirdCommitHash := env.GetHeadHash()
+ thirdCheckpointID := env.GetCheckpointIDFromCommitMessage(thirdCommitHash)
+
+ if thirdCheckpointID != "" {
+ t.Errorf("Third commit should NOT have checkpoint trailer "+
+ "(manual edit after session depleted), got %s", thirdCheckpointID)
+ } else {
+ t.Log("Third commit correctly has no checkpoint trailer (session depleted)")
+ }
+
+ t.Log("SessionDepleted_ManualEditNoCheckpoint test completed successfully")
+}
+
+// TestShadow_RevertedFiles_ManualEditNoCheckpoint tests that after reverting
+// uncommitted session files, manual edits with completely different content
+// do NOT get checkpoint trailers.
+//
+// The overlap check is content-aware: it compares file hashes between the
+// committed content and the shadow branch content. If they don't match,
+// the file is not considered session-related.
+//
+// Flow:
+// 1. Agent creates files A, B, C, then stops (IDLE)
+// 2. User commits files A and B → checkpoint #1
+// 3. User reverts file C (deletes it)
+// 4. User manually creates file C with different content
+// 5. User commits file C → NO checkpoint (content doesn't match shadow branch)
+func TestShadow_RevertedFiles_ManualEditNoCheckpoint(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ sess := env.NewSession()
+
+ // Start session
+ if err := env.SimulateUserPromptSubmitWithPrompt(sess.ID, "Create files A, B, and C"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // Create 3 files through session
+ env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
+ env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
+ env.WriteFile("fileC.go", "package main\n\nfunc C() {}\n")
+ sess.CreateTranscript("Create files A, B, and C", []FileChange{
+ {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
+ {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
+ {Path: "fileC.go", Content: "package main\n\nfunc C() {}\n"},
+ })
+
+ // Stop session (becomes IDLE)
+ if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // First commit: files A and B
+ env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go")
+ firstCommitHash := env.GetHeadHash()
+ firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
+ if firstCheckpointID == "" {
+ t.Fatal("First commit should have checkpoint trailer (files overlap with session)")
+ }
+ t.Logf("First checkpoint ID: %s", firstCheckpointID)
+
+ // Revert file C (undo agent's changes)
+ // Since fileC.go is a new file (untracked), we need to delete it
+ if err := os.Remove(filepath.Join(env.RepoDir, "fileC.go")); err != nil {
+ t.Fatalf("Failed to remove fileC.go: %v", err)
+ }
+ t.Log("Reverted fileC.go by removing it")
+
+ // Verify file C is gone
+ if _, err := os.Stat(filepath.Join(env.RepoDir, "fileC.go")); !os.IsNotExist(err) {
+ t.Fatal("fileC.go should not exist after revert")
+ }
+
+ // User manually creates file C with DIFFERENT content (not what agent wrote)
+ env.WriteFile("fileC.go", "package main\n\n// Completely different implementation\nfunc C() { panic(\"manual\") }\n")
+
+ // Commit the manual file C - should NOT get checkpoint because content-aware
+ // overlap check compares file hashes. The content is completely different
+ // from what the session wrote, so it's not linked.
+ env.GitCommitWithShadowHooks("Add file C (manual implementation)", "fileC.go")
+ secondCommitHash := env.GetHeadHash()
+ secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
+
+ if secondCheckpointID != "" {
+ t.Errorf("Second commit should NOT have checkpoint trailer "+
+ "(content doesn't match shadow branch), got %s", secondCheckpointID)
+ } else {
+ t.Log("Second commit correctly has no checkpoint trailer (content mismatch)")
+ }
+
+ t.Log("RevertedFiles_ManualEditNoCheckpoint test completed successfully")
+}
+
+// TestShadow_ResetSession_ClearsTurnCheckpointIDs tests that resetting a session
+// properly clears TurnCheckpointIDs and doesn't leave orphaned checkpoints.
+//
+// Flow:
+// 1. Agent starts working (ACTIVE)
+// 2. User commits mid-turn → TurnCheckpointIDs populated
+// 3. User calls "trace reset --session --force"
+// 4. Session state file should be deleted
+// 5. A new session can start cleanly without orphaned state
+func TestShadow_ResetSession_ClearsTurnCheckpointIDs(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ sess := env.NewSession()
+
+ // Start session (ACTIVE)
+ if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create feature function", sess.TranscriptPath); err != nil {
+ t.Fatalf("user-prompt-submit failed: %v", err)
+ }
+
+ // Create file and transcript
+ env.WriteFile("feature.go", "package main\n\nfunc Feature() {}\n")
+ sess.CreateTranscript("Create feature function", []FileChange{
+ {Path: "feature.go", Content: "package main\n\nfunc Feature() {}\n"},
+ })
+
+ // User commits while agent is still ACTIVE → TurnCheckpointIDs gets populated
+ env.GitCommitWithShadowHooks("Add feature", "feature.go")
+ commitHash := env.GetHeadHash()
+ checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash)
+ if checkpointID == "" {
+ t.Fatal("Commit should have checkpoint trailer")
+ }
+
+ // Verify TurnCheckpointIDs is populated
+ state, err := env.GetSessionState(sess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState failed: %v", err)
+ }
+ if len(state.TurnCheckpointIDs) == 0 {
+ t.Error("TurnCheckpointIDs should be populated after mid-turn commit")
+ }
+ t.Logf("TurnCheckpointIDs before reset: %v", state.TurnCheckpointIDs)
+
+ // Reset the session using the CLI
+ output, resetErr := env.RunCLIWithError("reset", "--session", sess.ID, "--force")
+ t.Logf("Reset output: %s", output)
+ if resetErr != nil {
+ t.Fatalf("Reset failed: %v", resetErr)
+ }
+
+ // Verify session state is cleared (file deleted)
+ state, err = env.GetSessionState(sess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState after reset failed unexpectedly: %v", err)
+ }
+ if state != nil {
+ t.Errorf("Session state should be nil after reset, got: phase=%s, TurnCheckpointIDs=%v",
+ state.Phase, state.TurnCheckpointIDs)
+ }
+
+ // Verify a new session can start cleanly
+ newSess := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithTranscriptPath(newSess.ID, newSess.TranscriptPath); err != nil {
+ t.Fatalf("user-prompt-submit for new session failed: %v", err)
+ }
+
+ newState, err := env.GetSessionState(newSess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState for new session failed: %v", err)
+ }
+ if newState == nil {
+ t.Fatal("New session state should exist")
+ }
+ if len(newState.TurnCheckpointIDs) != 0 {
+ t.Errorf("New session should have empty TurnCheckpointIDs, got: %v", newState.TurnCheckpointIDs)
+ }
+
+ t.Log("ResetSession_ClearsTurnCheckpointIDs test completed successfully")
+}
+
+// TestShadow_EndedSession_UserCommitsRemainingFiles tests that after a session ends
+// (IDLE → ENDED via session-end hook), user commits still get checkpoint trailers
+// and condensation happens correctly.
+//
+// This exercises the ENDED + GitCommit → ActionCondenseIfFilesTouched code path,
+// which is distinct from IDLE + GitCommit → ActionCondense.
+//
+// Flow:
+// 1. Agent creates files A and B, then stops (IDLE)
+// 2. Session ends (ENDED via SimulateSessionEnd)
+// 3. User commits file A → checkpoint #1
+// 4. User commits file B → checkpoint #2
+// 5. Both checkpoints exist, unique IDs, no shadow branches remain
+func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ sess := env.NewSession()
+
+ // Start session (ACTIVE)
+ if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create files A and B", sess.TranscriptPath); err != nil {
+ t.Fatalf("user-prompt-submit failed: %v", err)
+ }
+
+ // Create files
+ env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
+ env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
+
+ sess.CreateTranscript("Create files A and B", []FileChange{
+ {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
+ {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
+ })
+
+ // Stop session (IDLE)
+ if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ state, err := env.GetSessionState(sess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState failed: %v", err)
+ }
+ if state.Phase != session.PhaseIdle {
+ t.Errorf("Expected IDLE phase, got %s", state.Phase)
+ }
+
+ // End session (ENDED) — exercises the distinct ENDED code path
+ if err := env.SimulateSessionEnd(sess.ID); err != nil {
+ t.Fatalf("SimulateSessionEnd failed: %v", err)
+ }
+
+ state, err = env.GetSessionState(sess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState failed: %v", err)
+ }
+ if state.Phase != session.PhaseEnded {
+ t.Errorf("Expected ENDED phase, got %s", state.Phase)
+ }
+ if state.EndedAt == nil {
+ t.Error("EndedAt should be set after session-end")
+ }
+ t.Logf("Session phase: %s, EndedAt: %v, FilesTouched: %v",
+ state.Phase, state.EndedAt, state.FilesTouched)
+
+ // User commits file A → checkpoint #1
+ env.GitCommitWithShadowHooks("Add file A", "fileA.go")
+ firstCommitHash := env.GetHeadHash()
+ firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
+ if firstCheckpointID == "" {
+ t.Fatal("First commit should have checkpoint trailer (ENDED session, files overlap)")
+ }
+ t.Logf("First checkpoint ID: %s", firstCheckpointID)
+
+ // Verify phase stays ENDED
+ state, err = env.GetSessionState(sess.ID)
+ if err != nil {
+ t.Fatalf("GetSessionState failed: %v", err)
+ }
+ if state.Phase != session.PhaseEnded {
+ t.Errorf("Expected phase to stay ENDED after commit, got %s", state.Phase)
+ }
+
+ // Validate first checkpoint
+ env.ValidateCheckpoint(CheckpointValidation{
+ CheckpointID: firstCheckpointID,
+ SessionID: sess.ID,
+ FilesTouched: []string{"fileA.go"},
+ ExpectedPrompts: []string{"Create files A and B"},
+ })
+
+ // User commits file B → checkpoint #2
+ env.GitCommitWithShadowHooks("Add file B", "fileB.go")
+ secondCommitHash := env.GetHeadHash()
+ secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
+ if secondCheckpointID == "" {
+ t.Fatal("Second commit should have checkpoint trailer (carry-forward in ENDED)")
+ }
+ t.Logf("Second checkpoint ID: %s", secondCheckpointID)
+
+ // Checkpoint IDs must be unique
+ if firstCheckpointID == secondCheckpointID {
+ t.Errorf("Each commit should get a unique checkpoint ID.\nFirst: %s\nSecond: %s",
+ firstCheckpointID, secondCheckpointID)
+ }
+
+ // Validate second checkpoint
+ env.ValidateCheckpoint(CheckpointValidation{
+ CheckpointID: secondCheckpointID,
+ SessionID: sess.ID,
+ FilesTouched: []string{"fileB.go"},
+ ExpectedPrompts: []string{"Create files A and B"},
+ })
+
+ // No shadow branches should remain
+ branchesAfter := env.ListBranchesWithPrefix("trace/")
+ for _, b := range branchesAfter {
+ if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
+ t.Errorf("Unexpected shadow branch after all files committed: %s", b)
+ }
+ }
+
+ t.Log("EndedSession_UserCommitsRemainingFiles test completed successfully")
+}
+
+// TestShadow_DeletedFiles_CheckpointAndCarryForward tests that deleted files
+// in a session are properly handled: they get checkpoint trailers when committed
+// via git rm, and carry-forward works for remaining files.
+//
+// Flow:
+// 1. Pre-commit 3 files: old_a.go, old_b.go, old_c.go
+// 2. Session: agent creates new_file.go AND deletes old_a.go
+// 3. SimulateStop → IDLE
+// 4. User commits new_file.go → checkpoint #1
+// 5. User does git rm old_a.go + commit → checkpoint #2
+// 6. Both checkpoints validated, no shadow branches remain
+func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ // Pre-commit existing files
+ env.WriteFile("old_a.go", "package main\n\nfunc OldA() {}\n")
+ env.WriteFile("old_b.go", "package main\n\nfunc OldB() {}\n")
+ env.WriteFile("old_c.go", "package main\n\nfunc OldC() {}\n")
+ env.GitAdd("old_a.go")
+ env.GitAdd("old_b.go")
+ env.GitAdd("old_c.go")
+ env.GitCommit("Add old files")
+
+ sess := env.NewSession()
+
+ // Start session
+ if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create new_file.go and delete old_a.go", sess.TranscriptPath); err != nil {
+ t.Fatalf("user-prompt-submit failed: %v", err)
+ }
+
+ // Agent creates new_file.go and deletes old_a.go
+ env.WriteFile("new_file.go", "package main\n\nfunc NewFunc() {}\n")
+ if err := os.Remove(filepath.Join(env.RepoDir, "old_a.go")); err != nil {
+ t.Fatalf("Failed to delete old_a.go: %v", err)
+ }
+
+ sess.CreateTranscript("Create new_file.go and delete old_a.go", []FileChange{
+ {Path: "new_file.go", Content: "package main\n\nfunc NewFunc() {}\n"},
+ {Path: "old_a.go", Content: ""}, // deletion
+ })
+
+ // Stop session (IDLE)
+ if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // User commits new_file.go → checkpoint #1
+ env.GitCommitWithShadowHooks("Add new file", "new_file.go")
+ firstCommitHash := env.GetHeadHash()
+ firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
+ if firstCheckpointID == "" {
+ t.Fatal("First commit should have checkpoint trailer")
+ }
+ t.Logf("First checkpoint ID: %s", firstCheckpointID)
+
+ // User does git rm old_a.go and commits the deletion
+ env.GitRm("old_a.go")
+ env.GitCommitStagedWithShadowHooks("Remove old_a.go")
+ secondCommitHash := env.GetHeadHash()
+ secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
+ // Deleted files may get a trailer via carry-forward, but condensation may not
+ // produce full metadata since the file doesn't exist in the working tree.
+ // Just verify uniqueness if a trailer was added.
+ if secondCheckpointID != "" {
+ t.Logf("Second checkpoint ID: %s (carry-forward for deleted file)", secondCheckpointID)
+ if firstCheckpointID == secondCheckpointID {
+ t.Error("Checkpoint IDs should be unique")
+ }
+ } else {
+ t.Log("Second commit has no checkpoint trailer (deleted files may not carry forward)")
+ }
+
+ // Validate first checkpoint
+ env.ValidateCheckpoint(CheckpointValidation{
+ CheckpointID: firstCheckpointID,
+ SessionID: sess.ID,
+ ExpectedPrompts: []string{"Create new_file.go and delete old_a.go"},
+ })
+
+ // Check for remaining shadow branches.
+ // Note: deleted file carry-forward may leave shadow branches if condensation
+ // doesn't produce full metadata (known limitation).
+ branchesAfter := env.ListBranchesWithPrefix("trace/")
+ for _, b := range branchesAfter {
+ if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
+ t.Logf("Shadow branch remaining after commits (may be expected for deleted files): %s", b)
+ }
+ }
+
+ t.Log("DeletedFiles_CheckpointAndCarryForward test completed successfully")
+}
+
+// TestShadow_CarryForward_ModifiedExistingFiles tests that modified (not new) files
+// in carry-forward get checkpoint trailers correctly. Modified files always trigger
+// overlap because the user is editing a file the session worked on.
+//
+// Flow:
+// 1. Pre-commit 3 files: model.go, view.go, controller.go
+// 2. Session: agent modifies all three
+// 3. SimulateStop → IDLE
+// 4. User commits model.go → checkpoint #1
+// 5. User commits view.go → checkpoint #2
+// 6. User commits controller.go → checkpoint #3
+// 7. All IDs unique, all validated, no shadow branches
+func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) {
+ t.Parallel()
+
+ env := NewFeatureBranchEnv(t)
+
+ // Pre-commit existing files
+ env.WriteFile("model.go", "package main\n\nfunc Model() {}\n")
+ env.WriteFile("view.go", "package main\n\nfunc View() {}\n")
+ env.WriteFile("controller.go", "package main\n\nfunc Controller() {}\n")
+ env.GitAdd("model.go")
+ env.GitAdd("view.go")
+ env.GitAdd("controller.go")
+ env.GitCommit("Add MVC files")
+
+ sess := env.NewSession()
+
+ // Start session
+ if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Update MVC files", sess.TranscriptPath); err != nil {
+ t.Fatalf("user-prompt-submit failed: %v", err)
+ }
+
+ // Agent modifies all three files
+ env.WriteFile("model.go", "package main\n\n// Updated by agent\nfunc Model() { return }\n")
+ env.WriteFile("view.go", "package main\n\n// Updated by agent\nfunc View() { return }\n")
+ env.WriteFile("controller.go", "package main\n\n// Updated by agent\nfunc Controller() { return }\n")
+
+ sess.CreateTranscript("Update MVC files", []FileChange{
+ {Path: "model.go", Content: "package main\n\n// Updated by agent\nfunc Model() { return }\n"},
+ {Path: "view.go", Content: "package main\n\n// Updated by agent\nfunc View() { return }\n"},
+ {Path: "controller.go", Content: "package main\n\n// Updated by agent\nfunc Controller() { return }\n"},
+ })
+
+ // Stop session (IDLE)
+ if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // Commit each file separately
+ checkpointIDs := make([]string, 3)
+ files := []string{"model.go", "view.go", "controller.go"}
+
+ for i, file := range files {
+ env.GitCommitWithShadowHooks("Update "+file, file)
+ commitHash := env.GetHeadHash()
+ cpID := env.GetCheckpointIDFromCommitMessage(commitHash)
+ if cpID == "" {
+ t.Fatalf("Commit %d (%s) should have checkpoint trailer", i+1, file)
+ }
+ checkpointIDs[i] = cpID
+ t.Logf("Checkpoint %d (%s): %s", i+1, file, cpID)
+ }
+
+ // All checkpoint IDs must be unique
+ seen := make(map[string]bool)
+ for i, cpID := range checkpointIDs {
+ if seen[cpID] {
+ t.Errorf("Duplicate checkpoint ID at position %d: %s", i, cpID)
+ }
+ seen[cpID] = true
+ }
+
+ // Validate all checkpoints
+ for i, cpID := range checkpointIDs {
+ env.ValidateCheckpoint(CheckpointValidation{
+ CheckpointID: cpID,
+ SessionID: sess.ID,
+ FilesTouched: []string{files[i]},
+ ExpectedPrompts: []string{"Update MVC files"},
+ })
+ }
+
+ // No shadow branches should remain
+ branchesAfter := env.ListBranchesWithPrefix("trace/")
+ for _, b := range branchesAfter {
+ if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
+ t.Errorf("Unexpected shadow branch after all files committed: %s", b)
+ }
+ }
+
+ t.Log("CarryForward_ModifiedExistingFiles test completed successfully")
+}
diff --git a/cli/integration_test/deferred_finalization_test.go b/cli/integration_test/deferred_finalization_test.go
index 2b8c745..a18e728 100644
--- a/cli/integration_test/deferred_finalization_test.go
+++ b/cli/integration_test/deferred_finalization_test.go
@@ -813,566 +813,3 @@ func TestShadow_OverlapCheck_PartialOverlap(t *testing.T) {
t.Log("OverlapCheck_PartialOverlap test completed successfully")
}
-
-// TestShadow_SessionDepleted_ManualEditNoCheckpoint tests that once all session
-// files are committed, subsequent manual edits (even to previously committed files)
-// do NOT get checkpoint trailers.
-//
-// Flow:
-// 1. Agent creates files A, B, C, then stops (IDLE)
-// 2. User commits files A and B → checkpoint #1
-// 3. User commits file C → checkpoint #2 (carry-forward if implemented, or just C)
-// 4. Session is now "depleted" (all FilesTouched committed)
-// 5. User manually edits file A and commits → NO checkpoint (session exhausted)
-func TestShadow_SessionDepleted_ManualEditNoCheckpoint(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- sess := env.NewSession()
-
- // Start session
- if err := env.SimulateUserPromptSubmitWithPrompt(sess.ID, "Create files A, B, and C"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // Create 3 files through session
- env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
- env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
- env.WriteFile("fileC.go", "package main\n\nfunc C() {}\n")
- sess.CreateTranscript("Create files A, B, and C", []FileChange{
- {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
- {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
- {Path: "fileC.go", Content: "package main\n\nfunc C() {}\n"},
- })
-
- // Stop session (becomes IDLE)
- if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // First commit: files A and B
- env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go")
- firstCommitHash := env.GetHeadHash()
- firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
- if firstCheckpointID == "" {
- t.Fatal("First commit should have checkpoint trailer (files overlap with session)")
- }
- t.Logf("First checkpoint ID: %s", firstCheckpointID)
-
- // Second commit: file C
- env.GitCommitWithShadowHooks("Add file C", "fileC.go")
- secondCommitHash := env.GetHeadHash()
- secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
- // Note: Whether this gets a checkpoint depends on carry-forward implementation
- // for IDLE sessions. Log either way.
- if secondCheckpointID != "" {
- t.Logf("Second checkpoint ID: %s (carry-forward active for IDLE)", secondCheckpointID)
- } else {
- t.Log("Second commit has no checkpoint (IDLE sessions don't carry forward)")
- }
-
- // Verify session state - FilesTouched should be empty or session ended
- state, err := env.GetSessionState(sess.ID)
- if err != nil {
- // Session may have been cleaned up, which is fine
- t.Logf("Session state not found (may have been cleaned up): %v", err)
- } else {
- t.Logf("Session state after all commits: Phase=%s, FilesTouched=%v",
- state.Phase, state.FilesTouched)
- }
-
- // Now manually edit file A (which was already committed as part of session)
- env.WriteFile("fileA.go", "package main\n\n// Manual edit by user\nfunc A() { return }\n")
-
- // Commit the manual edit - should NOT get checkpoint
- env.GitCommitWithShadowHooks("Manual edit to file A", "fileA.go")
- thirdCommitHash := env.GetHeadHash()
- thirdCheckpointID := env.GetCheckpointIDFromCommitMessage(thirdCommitHash)
-
- if thirdCheckpointID != "" {
- t.Errorf("Third commit should NOT have checkpoint trailer "+
- "(manual edit after session depleted), got %s", thirdCheckpointID)
- } else {
- t.Log("Third commit correctly has no checkpoint trailer (session depleted)")
- }
-
- t.Log("SessionDepleted_ManualEditNoCheckpoint test completed successfully")
-}
-
-// TestShadow_RevertedFiles_ManualEditNoCheckpoint tests that after reverting
-// uncommitted session files, manual edits with completely different content
-// do NOT get checkpoint trailers.
-//
-// The overlap check is content-aware: it compares file hashes between the
-// committed content and the shadow branch content. If they don't match,
-// the file is not considered session-related.
-//
-// Flow:
-// 1. Agent creates files A, B, C, then stops (IDLE)
-// 2. User commits files A and B → checkpoint #1
-// 3. User reverts file C (deletes it)
-// 4. User manually creates file C with different content
-// 5. User commits file C → NO checkpoint (content doesn't match shadow branch)
-func TestShadow_RevertedFiles_ManualEditNoCheckpoint(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- sess := env.NewSession()
-
- // Start session
- if err := env.SimulateUserPromptSubmitWithPrompt(sess.ID, "Create files A, B, and C"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // Create 3 files through session
- env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
- env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
- env.WriteFile("fileC.go", "package main\n\nfunc C() {}\n")
- sess.CreateTranscript("Create files A, B, and C", []FileChange{
- {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
- {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
- {Path: "fileC.go", Content: "package main\n\nfunc C() {}\n"},
- })
-
- // Stop session (becomes IDLE)
- if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // First commit: files A and B
- env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go")
- firstCommitHash := env.GetHeadHash()
- firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
- if firstCheckpointID == "" {
- t.Fatal("First commit should have checkpoint trailer (files overlap with session)")
- }
- t.Logf("First checkpoint ID: %s", firstCheckpointID)
-
- // Revert file C (undo agent's changes)
- // Since fileC.go is a new file (untracked), we need to delete it
- if err := os.Remove(filepath.Join(env.RepoDir, "fileC.go")); err != nil {
- t.Fatalf("Failed to remove fileC.go: %v", err)
- }
- t.Log("Reverted fileC.go by removing it")
-
- // Verify file C is gone
- if _, err := os.Stat(filepath.Join(env.RepoDir, "fileC.go")); !os.IsNotExist(err) {
- t.Fatal("fileC.go should not exist after revert")
- }
-
- // User manually creates file C with DIFFERENT content (not what agent wrote)
- env.WriteFile("fileC.go", "package main\n\n// Completely different implementation\nfunc C() { panic(\"manual\") }\n")
-
- // Commit the manual file C - should NOT get checkpoint because content-aware
- // overlap check compares file hashes. The content is completely different
- // from what the session wrote, so it's not linked.
- env.GitCommitWithShadowHooks("Add file C (manual implementation)", "fileC.go")
- secondCommitHash := env.GetHeadHash()
- secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
-
- if secondCheckpointID != "" {
- t.Errorf("Second commit should NOT have checkpoint trailer "+
- "(content doesn't match shadow branch), got %s", secondCheckpointID)
- } else {
- t.Log("Second commit correctly has no checkpoint trailer (content mismatch)")
- }
-
- t.Log("RevertedFiles_ManualEditNoCheckpoint test completed successfully")
-}
-
-// TestShadow_ResetSession_ClearsTurnCheckpointIDs tests that resetting a session
-// properly clears TurnCheckpointIDs and doesn't leave orphaned checkpoints.
-//
-// Flow:
-// 1. Agent starts working (ACTIVE)
-// 2. User commits mid-turn → TurnCheckpointIDs populated
-// 3. User calls "trace reset --session --force"
-// 4. Session state file should be deleted
-// 5. A new session can start cleanly without orphaned state
-func TestShadow_ResetSession_ClearsTurnCheckpointIDs(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- sess := env.NewSession()
-
- // Start session (ACTIVE)
- if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create feature function", sess.TranscriptPath); err != nil {
- t.Fatalf("user-prompt-submit failed: %v", err)
- }
-
- // Create file and transcript
- env.WriteFile("feature.go", "package main\n\nfunc Feature() {}\n")
- sess.CreateTranscript("Create feature function", []FileChange{
- {Path: "feature.go", Content: "package main\n\nfunc Feature() {}\n"},
- })
-
- // User commits while agent is still ACTIVE → TurnCheckpointIDs gets populated
- env.GitCommitWithShadowHooks("Add feature", "feature.go")
- commitHash := env.GetHeadHash()
- checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash)
- if checkpointID == "" {
- t.Fatal("Commit should have checkpoint trailer")
- }
-
- // Verify TurnCheckpointIDs is populated
- state, err := env.GetSessionState(sess.ID)
- if err != nil {
- t.Fatalf("GetSessionState failed: %v", err)
- }
- if len(state.TurnCheckpointIDs) == 0 {
- t.Error("TurnCheckpointIDs should be populated after mid-turn commit")
- }
- t.Logf("TurnCheckpointIDs before reset: %v", state.TurnCheckpointIDs)
-
- // Reset the session using the CLI
- output, resetErr := env.RunCLIWithError("reset", "--session", sess.ID, "--force")
- t.Logf("Reset output: %s", output)
- if resetErr != nil {
- t.Fatalf("Reset failed: %v", resetErr)
- }
-
- // Verify session state is cleared (file deleted)
- state, err = env.GetSessionState(sess.ID)
- if err != nil {
- t.Fatalf("GetSessionState after reset failed unexpectedly: %v", err)
- }
- if state != nil {
- t.Errorf("Session state should be nil after reset, got: phase=%s, TurnCheckpointIDs=%v",
- state.Phase, state.TurnCheckpointIDs)
- }
-
- // Verify a new session can start cleanly
- newSess := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithTranscriptPath(newSess.ID, newSess.TranscriptPath); err != nil {
- t.Fatalf("user-prompt-submit for new session failed: %v", err)
- }
-
- newState, err := env.GetSessionState(newSess.ID)
- if err != nil {
- t.Fatalf("GetSessionState for new session failed: %v", err)
- }
- if newState == nil {
- t.Fatal("New session state should exist")
- }
- if len(newState.TurnCheckpointIDs) != 0 {
- t.Errorf("New session should have empty TurnCheckpointIDs, got: %v", newState.TurnCheckpointIDs)
- }
-
- t.Log("ResetSession_ClearsTurnCheckpointIDs test completed successfully")
-}
-
-// TestShadow_EndedSession_UserCommitsRemainingFiles tests that after a session ends
-// (IDLE → ENDED via session-end hook), user commits still get checkpoint trailers
-// and condensation happens correctly.
-//
-// This exercises the ENDED + GitCommit → ActionCondenseIfFilesTouched code path,
-// which is distinct from IDLE + GitCommit → ActionCondense.
-//
-// Flow:
-// 1. Agent creates files A and B, then stops (IDLE)
-// 2. Session ends (ENDED via SimulateSessionEnd)
-// 3. User commits file A → checkpoint #1
-// 4. User commits file B → checkpoint #2
-// 5. Both checkpoints exist, unique IDs, no shadow branches remain
-func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- sess := env.NewSession()
-
- // Start session (ACTIVE)
- if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create files A and B", sess.TranscriptPath); err != nil {
- t.Fatalf("user-prompt-submit failed: %v", err)
- }
-
- // Create files
- env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
- env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
-
- sess.CreateTranscript("Create files A and B", []FileChange{
- {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
- {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
- })
-
- // Stop session (IDLE)
- if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- state, err := env.GetSessionState(sess.ID)
- if err != nil {
- t.Fatalf("GetSessionState failed: %v", err)
- }
- if state.Phase != session.PhaseIdle {
- t.Errorf("Expected IDLE phase, got %s", state.Phase)
- }
-
- // End session (ENDED) — exercises the distinct ENDED code path
- if err := env.SimulateSessionEnd(sess.ID); err != nil {
- t.Fatalf("SimulateSessionEnd failed: %v", err)
- }
-
- state, err = env.GetSessionState(sess.ID)
- if err != nil {
- t.Fatalf("GetSessionState failed: %v", err)
- }
- if state.Phase != session.PhaseEnded {
- t.Errorf("Expected ENDED phase, got %s", state.Phase)
- }
- if state.EndedAt == nil {
- t.Error("EndedAt should be set after session-end")
- }
- t.Logf("Session phase: %s, EndedAt: %v, FilesTouched: %v",
- state.Phase, state.EndedAt, state.FilesTouched)
-
- // User commits file A → checkpoint #1
- env.GitCommitWithShadowHooks("Add file A", "fileA.go")
- firstCommitHash := env.GetHeadHash()
- firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
- if firstCheckpointID == "" {
- t.Fatal("First commit should have checkpoint trailer (ENDED session, files overlap)")
- }
- t.Logf("First checkpoint ID: %s", firstCheckpointID)
-
- // Verify phase stays ENDED
- state, err = env.GetSessionState(sess.ID)
- if err != nil {
- t.Fatalf("GetSessionState failed: %v", err)
- }
- if state.Phase != session.PhaseEnded {
- t.Errorf("Expected phase to stay ENDED after commit, got %s", state.Phase)
- }
-
- // Validate first checkpoint
- env.ValidateCheckpoint(CheckpointValidation{
- CheckpointID: firstCheckpointID,
- SessionID: sess.ID,
- FilesTouched: []string{"fileA.go"},
- ExpectedPrompts: []string{"Create files A and B"},
- })
-
- // User commits file B → checkpoint #2
- env.GitCommitWithShadowHooks("Add file B", "fileB.go")
- secondCommitHash := env.GetHeadHash()
- secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
- if secondCheckpointID == "" {
- t.Fatal("Second commit should have checkpoint trailer (carry-forward in ENDED)")
- }
- t.Logf("Second checkpoint ID: %s", secondCheckpointID)
-
- // Checkpoint IDs must be unique
- if firstCheckpointID == secondCheckpointID {
- t.Errorf("Each commit should get a unique checkpoint ID.\nFirst: %s\nSecond: %s",
- firstCheckpointID, secondCheckpointID)
- }
-
- // Validate second checkpoint
- env.ValidateCheckpoint(CheckpointValidation{
- CheckpointID: secondCheckpointID,
- SessionID: sess.ID,
- FilesTouched: []string{"fileB.go"},
- ExpectedPrompts: []string{"Create files A and B"},
- })
-
- // No shadow branches should remain
- branchesAfter := env.ListBranchesWithPrefix("trace/")
- for _, b := range branchesAfter {
- if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
- t.Errorf("Unexpected shadow branch after all files committed: %s", b)
- }
- }
-
- t.Log("EndedSession_UserCommitsRemainingFiles test completed successfully")
-}
-
-// TestShadow_DeletedFiles_CheckpointAndCarryForward tests that deleted files
-// in a session are properly handled: they get checkpoint trailers when committed
-// via git rm, and carry-forward works for remaining files.
-//
-// Flow:
-// 1. Pre-commit 3 files: old_a.go, old_b.go, old_c.go
-// 2. Session: agent creates new_file.go AND deletes old_a.go
-// 3. SimulateStop → IDLE
-// 4. User commits new_file.go → checkpoint #1
-// 5. User does git rm old_a.go + commit → checkpoint #2
-// 6. Both checkpoints validated, no shadow branches remain
-func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- // Pre-commit existing files
- env.WriteFile("old_a.go", "package main\n\nfunc OldA() {}\n")
- env.WriteFile("old_b.go", "package main\n\nfunc OldB() {}\n")
- env.WriteFile("old_c.go", "package main\n\nfunc OldC() {}\n")
- env.GitAdd("old_a.go")
- env.GitAdd("old_b.go")
- env.GitAdd("old_c.go")
- env.GitCommit("Add old files")
-
- sess := env.NewSession()
-
- // Start session
- if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Create new_file.go and delete old_a.go", sess.TranscriptPath); err != nil {
- t.Fatalf("user-prompt-submit failed: %v", err)
- }
-
- // Agent creates new_file.go and deletes old_a.go
- env.WriteFile("new_file.go", "package main\n\nfunc NewFunc() {}\n")
- if err := os.Remove(filepath.Join(env.RepoDir, "old_a.go")); err != nil {
- t.Fatalf("Failed to delete old_a.go: %v", err)
- }
-
- sess.CreateTranscript("Create new_file.go and delete old_a.go", []FileChange{
- {Path: "new_file.go", Content: "package main\n\nfunc NewFunc() {}\n"},
- {Path: "old_a.go", Content: ""}, // deletion
- })
-
- // Stop session (IDLE)
- if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // User commits new_file.go → checkpoint #1
- env.GitCommitWithShadowHooks("Add new file", "new_file.go")
- firstCommitHash := env.GetHeadHash()
- firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash)
- if firstCheckpointID == "" {
- t.Fatal("First commit should have checkpoint trailer")
- }
- t.Logf("First checkpoint ID: %s", firstCheckpointID)
-
- // User does git rm old_a.go and commits the deletion
- env.GitRm("old_a.go")
- env.GitCommitStagedWithShadowHooks("Remove old_a.go")
- secondCommitHash := env.GetHeadHash()
- secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash)
- // Deleted files may get a trailer via carry-forward, but condensation may not
- // produce full metadata since the file doesn't exist in the working tree.
- // Just verify uniqueness if a trailer was added.
- if secondCheckpointID != "" {
- t.Logf("Second checkpoint ID: %s (carry-forward for deleted file)", secondCheckpointID)
- if firstCheckpointID == secondCheckpointID {
- t.Error("Checkpoint IDs should be unique")
- }
- } else {
- t.Log("Second commit has no checkpoint trailer (deleted files may not carry forward)")
- }
-
- // Validate first checkpoint
- env.ValidateCheckpoint(CheckpointValidation{
- CheckpointID: firstCheckpointID,
- SessionID: sess.ID,
- ExpectedPrompts: []string{"Create new_file.go and delete old_a.go"},
- })
-
- // Check for remaining shadow branches.
- // Note: deleted file carry-forward may leave shadow branches if condensation
- // doesn't produce full metadata (known limitation).
- branchesAfter := env.ListBranchesWithPrefix("trace/")
- for _, b := range branchesAfter {
- if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
- t.Logf("Shadow branch remaining after commits (may be expected for deleted files): %s", b)
- }
- }
-
- t.Log("DeletedFiles_CheckpointAndCarryForward test completed successfully")
-}
-
-// TestShadow_CarryForward_ModifiedExistingFiles tests that modified (not new) files
-// in carry-forward get checkpoint trailers correctly. Modified files always trigger
-// overlap because the user is editing a file the session worked on.
-//
-// Flow:
-// 1. Pre-commit 3 files: model.go, view.go, controller.go
-// 2. Session: agent modifies all three
-// 3. SimulateStop → IDLE
-// 4. User commits model.go → checkpoint #1
-// 5. User commits view.go → checkpoint #2
-// 6. User commits controller.go → checkpoint #3
-// 7. All IDs unique, all validated, no shadow branches
-func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) {
- t.Parallel()
-
- env := NewFeatureBranchEnv(t)
-
- // Pre-commit existing files
- env.WriteFile("model.go", "package main\n\nfunc Model() {}\n")
- env.WriteFile("view.go", "package main\n\nfunc View() {}\n")
- env.WriteFile("controller.go", "package main\n\nfunc Controller() {}\n")
- env.GitAdd("model.go")
- env.GitAdd("view.go")
- env.GitAdd("controller.go")
- env.GitCommit("Add MVC files")
-
- sess := env.NewSession()
-
- // Start session
- if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, "Update MVC files", sess.TranscriptPath); err != nil {
- t.Fatalf("user-prompt-submit failed: %v", err)
- }
-
- // Agent modifies all three files
- env.WriteFile("model.go", "package main\n\n// Updated by agent\nfunc Model() { return }\n")
- env.WriteFile("view.go", "package main\n\n// Updated by agent\nfunc View() { return }\n")
- env.WriteFile("controller.go", "package main\n\n// Updated by agent\nfunc Controller() { return }\n")
-
- sess.CreateTranscript("Update MVC files", []FileChange{
- {Path: "model.go", Content: "package main\n\n// Updated by agent\nfunc Model() { return }\n"},
- {Path: "view.go", Content: "package main\n\n// Updated by agent\nfunc View() { return }\n"},
- {Path: "controller.go", Content: "package main\n\n// Updated by agent\nfunc Controller() { return }\n"},
- })
-
- // Stop session (IDLE)
- if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // Commit each file separately
- checkpointIDs := make([]string, 3)
- files := []string{"model.go", "view.go", "controller.go"}
-
- for i, file := range files {
- env.GitCommitWithShadowHooks("Update "+file, file)
- commitHash := env.GetHeadHash()
- cpID := env.GetCheckpointIDFromCommitMessage(commitHash)
- if cpID == "" {
- t.Fatalf("Commit %d (%s) should have checkpoint trailer", i+1, file)
- }
- checkpointIDs[i] = cpID
- t.Logf("Checkpoint %d (%s): %s", i+1, file, cpID)
- }
-
- // All checkpoint IDs must be unique
- seen := make(map[string]bool)
- for i, cpID := range checkpointIDs {
- if seen[cpID] {
- t.Errorf("Duplicate checkpoint ID at position %d: %s", i, cpID)
- }
- seen[cpID] = true
- }
-
- // Validate all checkpoints
- for i, cpID := range checkpointIDs {
- env.ValidateCheckpoint(CheckpointValidation{
- CheckpointID: cpID,
- SessionID: sess.ID,
- FilesTouched: []string{files[i]},
- ExpectedPrompts: []string{"Update MVC files"},
- })
- }
-
- // No shadow branches should remain
- branchesAfter := env.ListBranchesWithPrefix("trace/")
- for _, b := range branchesAfter {
- if b != paths.MetadataBranchName && b != paths.TrailsBranchName {
- t.Errorf("Unexpected shadow branch after all files committed: %s", b)
- }
- }
-
- t.Log("CarryForward_ModifiedExistingFiles test completed successfully")
-}
diff --git a/cli/integration_test/explain_2_test.go b/cli/integration_test/explain_2_test.go
new file mode 100644
index 0000000..579d30f
--- /dev/null
+++ b/cli/integration_test/explain_2_test.go
@@ -0,0 +1,216 @@
+//go:build integration
+
+package integration
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/execx"
+ "github.com/GrayCodeAI/trace/cli/jsonutil"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+// TestExplain_CheckpointV2SucceedsAfterTreelessFetch is the v2 mirror —
+// guards V2GitStore's read path against the same blob-missing regression.
+// Required because v2 will be enabled by default soon and reaches the
+// same Tree.File() trap as v1.
+func TestExplain_CheckpointV2SucceedsAfterTreelessFetch(t *testing.T) {
+ t.Parallel()
+ env := NewFeatureBranchEnv(t)
+
+ env.PatchSettings(map[string]any{
+ "strategy_options": map[string]any{
+ "checkpoints_v2": true,
+ "push_v2_refs": true,
+ },
+ })
+
+ bareURL := env.SetupBareRemote()
+ checkpointID := createAndPushCheckpoint(t, env, "treeless_v2.go", "Treeless v2 prompt")
+
+ cloneDir := setupTreelessClone(t, bareURL, "+"+paths.V2MainRefName+":"+paths.V2MainRefName)
+ writeV2Settings(t, cloneDir)
+ requireBlobMissing(t, cloneDir, checkpointID, true /* v2 */)
+
+ output := runExplainInDir(t, cloneDir, checkpointID)
+ require.Contains(t, output, "Treeless v2 prompt",
+ "explain should succeed against v2 with blobs absent locally")
+}
+
+// createAndPushCheckpoint runs a session-create-stop cycle in env and
+// pushes the resulting checkpoint to origin. Returns the checkpoint ID.
+func createAndPushCheckpoint(t *testing.T, env *TestEnv, fileName, prompt string) string {
+ t.Helper()
+ session := env.NewSession()
+ transcriptPath := session.CreateTranscript(prompt, []FileChange{
+ {Path: fileName, Content: "package treeless"},
+ })
+ require.NoError(t, env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(session.ID, prompt, transcriptPath))
+ env.WriteFile(fileName, "package treeless")
+ env.GitAdd(fileName)
+ require.NoError(t, env.SimulateStop(session.ID, transcriptPath))
+ env.GitCommitWithShadowHooks("Add "+fileName, fileName)
+ cpID := env.GetLatestCheckpointID()
+ require.NotEmpty(t, cpID, "expected a checkpoint after condensation")
+ env.RunPrePush("origin")
+ return cpID
+}
+
+// setupTreelessClone creates a fresh git repo in a fresh TempDir, fetches
+// the given refspec from bareURL with --filter=blob:none --depth=1 (so
+// trees but no blobs land locally), and writes a minimal trace settings
+// file pointing at bareURL as the checkpoint_remote. Returns the new dir.
+//
+// Note: the bare and the fetch must go through the smart protocol for
+// --filter to be honored; the default local-path transport optimization
+// copies packs verbatim and ignores filters. We set
+// uploadpack.allowFilter=true on the bare and use a file:// URL with
+// protocol.file.allow=always to force the smart path.
+func setupTreelessClone(t *testing.T, barePath, refspec string) string {
+ t.Helper()
+ gitEnv := testutil.GitIsolatedEnv()
+ enableFilterOnBare(t, barePath, gitEnv)
+
+ cloneDir := t.TempDir()
+ fileURL := "file://" + barePath
+
+ for _, args := range [][]string{
+ {"init", "-q"},
+ {"-c", "protocol.file.allow=always", "fetch", "--filter=blob:none", "--depth=1", "--no-tags", fileURL, refspec},
+ } {
+ cmd := exec.CommandContext(t.Context(), "git", args...)
+ cmd.Dir = cloneDir
+ cmd.Env = gitEnv
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ }
+
+ require.NoError(t, writeMinimalTraceSettings(cloneDir, barePath))
+ return cloneDir
+}
+
+// enableFilterOnBare sets uploadpack.allowFilter=true on the bare repo so
+// that --filter=blob:none on fetch is honored.
+func enableFilterOnBare(t *testing.T, barePath string, gitEnv []string) {
+ t.Helper()
+ cmd := exec.CommandContext(t.Context(), "git", "-C", barePath, "config", "uploadpack.allowFilter", "true")
+ cmd.Env = gitEnv
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("failed to set uploadpack.allowFilter on bare: %v\n%s", err, out)
+ }
+ cmd = exec.CommandContext(t.Context(), "git", "-C", barePath, "config", "uploadpack.allowAnySHA1InWant", "true")
+ cmd.Env = gitEnv
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("failed to set uploadpack.allowAnySHA1InWant on bare: %v\n%s", err, out)
+ }
+}
+
+// writeMinimalTraceSettings writes the smallest valid settings.json that
+// configures the manual-commit strategy with filtered_fetches enabled and
+// a custom checkpoint_remote URL — the partial-clone setup that triggered
+// the original bug.
+func writeMinimalTraceSettings(dir, bareURL string) error {
+ traceDir := filepath.Join(dir, ".trace")
+ if err := os.MkdirAll(traceDir, 0o755); err != nil {
+ return err
+ }
+ settings := map[string]any{
+ "enabled": true,
+ "local_dev": true,
+ "strategy": "manual-commit",
+ "strategy_options": map[string]any{
+ "filtered_fetches": true,
+ "checkpoint_remote": map[string]any{
+ "provider": "url",
+ "url": bareURL,
+ },
+ },
+ }
+ data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o644)
+}
+
+// writeV2Settings overlays checkpoints_v2 enablement on the settings written
+// by writeMinimalTraceSettings.
+func writeV2Settings(t *testing.T, dir string) {
+ t.Helper()
+ settingsPath := filepath.Join(dir, ".trace", paths.SettingsFileName)
+ data, err := os.ReadFile(settingsPath)
+ require.NoError(t, err)
+
+ var settings map[string]any
+ require.NoError(t, json.Unmarshal(data, &settings))
+
+ opts, _ := settings["strategy_options"].(map[string]any)
+ opts["checkpoints_v2"] = true
+ settings["strategy_options"] = opts
+
+ updated, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(settingsPath, updated, 0o644))
+}
+
+// runExplainInDir runs `trace explain --checkpoint ` in dir and
+// returns combined output. Fails the test if the command errors. Uses
+// execx.NonInteractive (project rule for spawning the trace binary in
+// tests) so the child has no controlling terminal.
+func runExplainInDir(t *testing.T, dir, checkpointID string) string {
+ t.Helper()
+ cmd := execx.NonInteractive(t.Context(), getTestBinary(), "explain", "--checkpoint", checkpointID)
+ cmd.Dir = dir
+ cmd.Env = testutil.GitIsolatedEnv()
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("explain failed: %v\n%s", err, out)
+ }
+ return string(out)
+}
+
+// requireBlobMissing asserts that at least one metadata blob for the
+// checkpoint is genuinely absent from the local object store. Confirms the
+// treeless-clone setup actually reproduces the bug-triggering state — if
+// every blob were locally available, the test would pass without
+// exercising the fix.
+func requireBlobMissing(t *testing.T, dir, checkpointID string, isV2 bool) {
+ t.Helper()
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ var ref *plumbing.Reference
+ if isV2 {
+ ref, err = repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ } else {
+ ref, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ }
+ require.NoError(t, err, "metadata ref should exist after treeless fetch")
+
+ commit, err := repo.CommitObject(ref.Hash())
+ require.NoError(t, err)
+ rootTree, err := commit.Tree()
+ require.NoError(t, err)
+ cpSubtree, err := rootTree.Tree(checkpointID[:2] + "/" + checkpointID[2:])
+ require.NoError(t, err, "cp subtree should be navigable from local trees")
+
+ for _, entry := range cpSubtree.Entries {
+ if !entry.Mode.IsFile() {
+ continue
+ }
+ if _, err := repo.BlobObject(entry.Hash); err != nil {
+ return // confirmed: at least one blob is missing
+ }
+ }
+ t.Fatalf("expected at least one metadata blob to be missing in fresh treeless clone (cp=%s, v2=%v)", checkpointID, isV2)
+}
diff --git a/cli/integration_test/explain_test.go b/cli/integration_test/explain_test.go
index ed43aff..07daa5f 100644
--- a/cli/integration_test/explain_test.go
+++ b/cli/integration_test/explain_test.go
@@ -4,19 +4,12 @@ package integration
import (
"context"
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
"strings"
"testing"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/execx"
- "github.com/GrayCodeAI/trace/cli/jsonutil"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/testutil"
"github.com/GrayCodeAI/trace/redact"
"github.com/stretchr/testify/require"
@@ -824,199 +817,3 @@ func TestExplain_CheckpointSucceedsAfterTreelessFetch(t *testing.T) {
require.Contains(t, output, "Treeless v1 prompt",
"explain should succeed and surface the prompt despite blobs being absent locally")
}
-
-// TestExplain_CheckpointV2SucceedsAfterTreelessFetch is the v2 mirror —
-// guards V2GitStore's read path against the same blob-missing regression.
-// Required because v2 will be enabled by default soon and reaches the
-// same Tree.File() trap as v1.
-func TestExplain_CheckpointV2SucceedsAfterTreelessFetch(t *testing.T) {
- t.Parallel()
- env := NewFeatureBranchEnv(t)
-
- env.PatchSettings(map[string]any{
- "strategy_options": map[string]any{
- "checkpoints_v2": true,
- "push_v2_refs": true,
- },
- })
-
- bareURL := env.SetupBareRemote()
- checkpointID := createAndPushCheckpoint(t, env, "treeless_v2.go", "Treeless v2 prompt")
-
- cloneDir := setupTreelessClone(t, bareURL, "+"+paths.V2MainRefName+":"+paths.V2MainRefName)
- writeV2Settings(t, cloneDir)
- requireBlobMissing(t, cloneDir, checkpointID, true /* v2 */)
-
- output := runExplainInDir(t, cloneDir, checkpointID)
- require.Contains(t, output, "Treeless v2 prompt",
- "explain should succeed against v2 with blobs absent locally")
-}
-
-// createAndPushCheckpoint runs a session-create-stop cycle in env and
-// pushes the resulting checkpoint to origin. Returns the checkpoint ID.
-func createAndPushCheckpoint(t *testing.T, env *TestEnv, fileName, prompt string) string {
- t.Helper()
- session := env.NewSession()
- transcriptPath := session.CreateTranscript(prompt, []FileChange{
- {Path: fileName, Content: "package treeless"},
- })
- require.NoError(t, env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(session.ID, prompt, transcriptPath))
- env.WriteFile(fileName, "package treeless")
- env.GitAdd(fileName)
- require.NoError(t, env.SimulateStop(session.ID, transcriptPath))
- env.GitCommitWithShadowHooks("Add "+fileName, fileName)
- cpID := env.GetLatestCheckpointID()
- require.NotEmpty(t, cpID, "expected a checkpoint after condensation")
- env.RunPrePush("origin")
- return cpID
-}
-
-// setupTreelessClone creates a fresh git repo in a fresh TempDir, fetches
-// the given refspec from bareURL with --filter=blob:none --depth=1 (so
-// trees but no blobs land locally), and writes a minimal trace settings
-// file pointing at bareURL as the checkpoint_remote. Returns the new dir.
-//
-// Note: the bare and the fetch must go through the smart protocol for
-// --filter to be honored; the default local-path transport optimization
-// copies packs verbatim and ignores filters. We set
-// uploadpack.allowFilter=true on the bare and use a file:// URL with
-// protocol.file.allow=always to force the smart path.
-func setupTreelessClone(t *testing.T, barePath, refspec string) string {
- t.Helper()
- gitEnv := testutil.GitIsolatedEnv()
- enableFilterOnBare(t, barePath, gitEnv)
-
- cloneDir := t.TempDir()
- fileURL := "file://" + barePath
-
- for _, args := range [][]string{
- {"init", "-q"},
- {"-c", "protocol.file.allow=always", "fetch", "--filter=blob:none", "--depth=1", "--no-tags", fileURL, refspec},
- } {
- cmd := exec.CommandContext(t.Context(), "git", args...)
- cmd.Dir = cloneDir
- cmd.Env = gitEnv
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git %v failed: %v\n%s", args, err, out)
- }
- }
-
- require.NoError(t, writeMinimalTraceSettings(cloneDir, barePath))
- return cloneDir
-}
-
-// enableFilterOnBare sets uploadpack.allowFilter=true on the bare repo so
-// that --filter=blob:none on fetch is honored.
-func enableFilterOnBare(t *testing.T, barePath string, gitEnv []string) {
- t.Helper()
- cmd := exec.CommandContext(t.Context(), "git", "-C", barePath, "config", "uploadpack.allowFilter", "true")
- cmd.Env = gitEnv
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("failed to set uploadpack.allowFilter on bare: %v\n%s", err, out)
- }
- cmd = exec.CommandContext(t.Context(), "git", "-C", barePath, "config", "uploadpack.allowAnySHA1InWant", "true")
- cmd.Env = gitEnv
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("failed to set uploadpack.allowAnySHA1InWant on bare: %v\n%s", err, out)
- }
-}
-
-// writeMinimalTraceSettings writes the smallest valid settings.json that
-// configures the manual-commit strategy with filtered_fetches enabled and
-// a custom checkpoint_remote URL — the partial-clone setup that triggered
-// the original bug.
-func writeMinimalTraceSettings(dir, bareURL string) error {
- traceDir := filepath.Join(dir, ".trace")
- if err := os.MkdirAll(traceDir, 0o755); err != nil {
- return err
- }
- settings := map[string]any{
- "enabled": true,
- "local_dev": true,
- "strategy": "manual-commit",
- "strategy_options": map[string]any{
- "filtered_fetches": true,
- "checkpoint_remote": map[string]any{
- "provider": "url",
- "url": bareURL,
- },
- },
- }
- data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o644)
-}
-
-// writeV2Settings overlays checkpoints_v2 enablement on the settings written
-// by writeMinimalTraceSettings.
-func writeV2Settings(t *testing.T, dir string) {
- t.Helper()
- settingsPath := filepath.Join(dir, ".trace", paths.SettingsFileName)
- data, err := os.ReadFile(settingsPath)
- require.NoError(t, err)
-
- var settings map[string]any
- require.NoError(t, json.Unmarshal(data, &settings))
-
- opts, _ := settings["strategy_options"].(map[string]any)
- opts["checkpoints_v2"] = true
- settings["strategy_options"] = opts
-
- updated, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(settingsPath, updated, 0o644))
-}
-
-// runExplainInDir runs `trace explain --checkpoint ` in dir and
-// returns combined output. Fails the test if the command errors. Uses
-// execx.NonInteractive (project rule for spawning the trace binary in
-// tests) so the child has no controlling terminal.
-func runExplainInDir(t *testing.T, dir, checkpointID string) string {
- t.Helper()
- cmd := execx.NonInteractive(t.Context(), getTestBinary(), "explain", "--checkpoint", checkpointID)
- cmd.Dir = dir
- cmd.Env = testutil.GitIsolatedEnv()
- out, err := cmd.CombinedOutput()
- if err != nil {
- t.Fatalf("explain failed: %v\n%s", err, out)
- }
- return string(out)
-}
-
-// requireBlobMissing asserts that at least one metadata blob for the
-// checkpoint is genuinely absent from the local object store. Confirms the
-// treeless-clone setup actually reproduces the bug-triggering state — if
-// every blob were locally available, the test would pass without
-// exercising the fix.
-func requireBlobMissing(t *testing.T, dir, checkpointID string, isV2 bool) {
- t.Helper()
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- var ref *plumbing.Reference
- if isV2 {
- ref, err = repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- } else {
- ref, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- }
- require.NoError(t, err, "metadata ref should exist after treeless fetch")
-
- commit, err := repo.CommitObject(ref.Hash())
- require.NoError(t, err)
- rootTree, err := commit.Tree()
- require.NoError(t, err)
- cpSubtree, err := rootTree.Tree(checkpointID[:2] + "/" + checkpointID[2:])
- require.NoError(t, err, "cp subtree should be navigable from local trees")
-
- for _, entry := range cpSubtree.Entries {
- if !entry.Mode.IsFile() {
- continue
- }
- if _, err := repo.BlobObject(entry.Hash); err != nil {
- return // confirmed: at least one blob is missing
- }
- }
- t.Fatalf("expected at least one metadata blob to be missing in fresh treeless clone (cp=%s, v2=%v)", checkpointID, isV2)
-}
diff --git a/cli/integration_test/hooks.go b/cli/integration_test/hooks.go
index b547d54..0a51c25 100644
--- a/cli/integration_test/hooks.go
+++ b/cli/integration_test/hooks.go
@@ -813,741 +813,3 @@ func (env *TestEnv) SimulateGeminiBeforeAgent(sessionID string) error {
runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
return runner.SimulateGeminiBeforeAgent(sessionID)
}
-
-// SimulateGeminiBeforeAgentWithOutput is a convenience method on TestEnv.
-func (env *TestEnv) SimulateGeminiBeforeAgentWithOutput(sessionID string) HookOutput {
- env.T.Helper()
- runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
- return runner.SimulateGeminiBeforeAgentWithOutput(sessionID)
-}
-
-// SimulateGeminiAfterAgent is a convenience method on TestEnv.
-func (env *TestEnv) SimulateGeminiAfterAgent(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
- return runner.SimulateGeminiAfterAgent(sessionID, transcriptPath)
-}
-
-// SimulateGeminiSessionEnd is a convenience method on TestEnv.
-func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
- return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath)
-}
-
-// --- Factory AI Droid Hook Runner ---
-
-// FactoryDroidHookRunner executes Factory AI Droid hooks in the test environment.
-type FactoryDroidHookRunner struct {
- RepoDir string
- T interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
- }
-}
-
-// NewFactoryDroidHookRunner creates a new Factory Droid hook runner.
-func NewFactoryDroidHookRunner(repoDir string, t interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
-},
-) *FactoryDroidHookRunner {
- return &FactoryDroidHookRunner{
- RepoDir: repoDir,
- T: t,
- }
-}
-
-// runDroidHookWithInput runs a Factory Droid hook with the given input.
-func (r *FactoryDroidHookRunner) runDroidHookWithInput(hookName string, input interface{}) error {
- r.T.Helper()
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- return fmt.Errorf("failed to marshal hook input: %w", err)
- }
-
- return r.runDroidHookInRepoDir(hookName, inputJSON)
-}
-
-func (r *FactoryDroidHookRunner) runDroidHookInRepoDir(hookName string, inputJSON []byte) error {
- cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName)
- cmd.Dir = r.RepoDir
- cmd.Stdin = bytes.NewReader(inputJSON)
- cmd.Env = os.Environ()
-
- output, err := cmd.CombinedOutput()
- if err != nil {
- return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s",
- hookName, err, inputJSON, output)
- }
-
- r.T.Logf("Droid hook %s output: %s", hookName, output)
- return nil
-}
-
-// runDroidHookWithOutput runs a Factory Droid hook and returns both stdout and stderr separately.
-func (r *FactoryDroidHookRunner) runDroidHookWithOutput(hookName string, inputJSON []byte) HookOutput {
- cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName)
- cmd.Dir = r.RepoDir
- cmd.Stdin = bytes.NewReader(inputJSON)
- cmd.Env = os.Environ()
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- err := cmd.Run()
- return HookOutput{
- Stdout: stdout.Bytes(),
- Stderr: stderr.Bytes(),
- Err: err,
- }
-}
-
-// SimulateUserPromptSubmit simulates the UserPromptSubmit hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulateUserPromptSubmit(sessionID string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": "",
- "prompt": "test prompt",
- }
-
- return r.runDroidHookWithInput("user-prompt-submit", input)
-}
-
-// SimulateUserPromptSubmitWithOutput simulates the UserPromptSubmit hook and returns the output.
-func (r *FactoryDroidHookRunner) SimulateUserPromptSubmitWithOutput(sessionID string) HookOutput {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": "",
- "prompt": "test prompt",
- }
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)}
- }
-
- return r.runDroidHookWithOutput("user-prompt-submit", inputJSON)
-}
-
-// SimulateStop simulates the Stop hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulateStop(sessionID, transcriptPath string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": transcriptPath,
- }
-
- return r.runDroidHookWithInput("stop", input)
-}
-
-// SimulateSessionStart simulates the SessionStart hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulateSessionStart(sessionID string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": "",
- }
-
- return r.runDroidHookWithInput("session-start", input)
-}
-
-// SimulateSessionStartWithOutput simulates the SessionStart hook and returns the output.
-func (r *FactoryDroidHookRunner) SimulateSessionStartWithOutput(sessionID string) HookOutput {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": "",
- }
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)}
- }
-
- return r.runDroidHookWithOutput("session-start", inputJSON)
-}
-
-// SimulateSessionEnd simulates the SessionEnd hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulateSessionEnd(sessionID, transcriptPath string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "transcript_path": transcriptPath,
- }
-
- return r.runDroidHookWithInput("session-end", input)
-}
-
-// SimulatePreTask simulates the PreToolUse[Task] hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulatePreTask(sessionID, transcriptPath, toolUseID string) error {
- r.T.Helper()
-
- input := map[string]interface{}{
- "session_id": sessionID,
- "transcript_path": transcriptPath,
- "tool_use_id": toolUseID,
- "tool_input": map[string]string{
- "subagent_type": "general-purpose",
- "description": "test task",
- },
- }
-
- return r.runDroidHookWithInput("pre-tool-use", input)
-}
-
-// SimulatePostTask simulates the PostToolUse[Task] hook for Factory Droid.
-func (r *FactoryDroidHookRunner) SimulatePostTask(input PostTaskInput) error {
- r.T.Helper()
-
- hookInput := map[string]interface{}{
- "session_id": input.SessionID,
- "transcript_path": input.TranscriptPath,
- "tool_use_id": input.ToolUseID,
- "tool_input": map[string]string{},
- "tool_response": map[string]string{
- "agentId": input.AgentID,
- },
- }
-
- return r.runDroidHookWithInput("post-tool-use", hookInput)
-}
-
-// FactoryDroidSession represents a simulated Factory AI Droid session.
-type FactoryDroidSession struct {
- ID string
- TranscriptPath string
- env *TestEnv
-}
-
-// NewFactoryDroidSession creates a new simulated Factory Droid session.
-func (env *TestEnv) NewFactoryDroidSession() *FactoryDroidSession {
- env.T.Helper()
-
- env.SessionCounter++
- sessionID := fmt.Sprintf("droid-session-%d", env.SessionCounter)
- transcriptPath := filepath.Join(env.RepoDir, ".trace", "tmp", sessionID+".jsonl")
-
- return &FactoryDroidSession{
- ID: sessionID,
- TranscriptPath: transcriptPath,
- env: env,
- }
-}
-
-// CreateDroidTranscript creates a Droid-envelope JSONL transcript file.
-// Droid wraps messages as {"type":"message","id":"...","message":{"role":"...","content":[...]}},
-// unlike Claude Code which uses {"type":"assistant","uuid":"...","message":{"content":[...]}}.
-func (s *FactoryDroidSession) CreateDroidTranscript(prompt string, changes []FileChange) string {
- var lines []map[string]interface{}
-
- // User message with prompt
- lines = append(lines, map[string]interface{}{
- "type": "message",
- "id": "m1",
- "message": map[string]interface{}{
- "role": "user",
- "content": []map[string]interface{}{
- {"type": "text", "text": prompt},
- },
- },
- })
-
- // Assistant message with tool uses
- assistantContent := []interface{}{
- map[string]interface{}{"type": "text", "text": "I'll help you with that."},
- }
- for i, change := range changes {
- assistantContent = append(assistantContent, map[string]interface{}{
- "type": "tool_use",
- "id": fmt.Sprintf("toolu_%d", i+1),
- "name": "Write",
- "input": map[string]string{"file_path": change.Path, "content": change.Content},
- })
- }
- lines = append(lines, map[string]interface{}{
- "type": "message",
- "id": "m2",
- "message": map[string]interface{}{
- "role": "assistant",
- "content": assistantContent,
- },
- })
-
- // Tool results
- toolResultContent := make([]map[string]interface{}, 0, len(changes))
- for i := range changes {
- toolResultContent = append(toolResultContent, map[string]interface{}{
- "type": "tool_result",
- "tool_use_id": fmt.Sprintf("toolu_%d", i+1),
- "content": "Success",
- })
- }
- lines = append(lines, map[string]interface{}{
- "type": "message",
- "id": "m3",
- "message": map[string]interface{}{
- "role": "user",
- "content": toolResultContent,
- },
- })
-
- // Final assistant message
- lines = append(lines, map[string]interface{}{
- "type": "message",
- "id": "m4",
- "message": map[string]interface{}{
- "role": "assistant",
- "content": []map[string]interface{}{
- {"type": "text", "text": "Done!"},
- },
- },
- })
-
- // Ensure directory exists
- if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil {
- s.env.T.Fatalf("failed to create transcript dir: %v", err)
- }
-
- // Write as JSONL
- file, err := os.Create(s.TranscriptPath)
- if err != nil {
- s.env.T.Fatalf("failed to create transcript file: %v", err)
- }
- defer func() { _ = file.Close() }()
-
- encoder := json.NewEncoder(file)
- for _, line := range lines {
- if err := encoder.Encode(line); err != nil {
- s.env.T.Fatalf("failed to encode transcript line: %v", err)
- }
- }
-
- return s.TranscriptPath
-}
-
-// SimulateFactoryDroidUserPromptSubmit is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidUserPromptSubmit(sessionID string) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateUserPromptSubmit(sessionID)
-}
-
-// SimulateFactoryDroidUserPromptSubmitWithOutput is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidUserPromptSubmitWithOutput(sessionID string) HookOutput {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateUserPromptSubmitWithOutput(sessionID)
-}
-
-// SimulateFactoryDroidStop is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidStop(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateStop(sessionID, transcriptPath)
-}
-
-// SimulateFactoryDroidSessionStart is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidSessionStart(sessionID string) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateSessionStart(sessionID)
-}
-
-// SimulateFactoryDroidSessionStartWithOutput is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidSessionStartWithOutput(sessionID string) HookOutput {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateSessionStartWithOutput(sessionID)
-}
-
-// SimulateFactoryDroidSessionEnd is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidSessionEnd(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulateSessionEnd(sessionID, transcriptPath)
-}
-
-// SimulateFactoryDroidPreTask is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidPreTask(sessionID, transcriptPath, toolUseID string) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulatePreTask(sessionID, transcriptPath, toolUseID)
-}
-
-// SimulateFactoryDroidPostTask is a convenience method on TestEnv.
-func (env *TestEnv) SimulateFactoryDroidPostTask(input PostTaskInput) error {
- env.T.Helper()
- runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
- return runner.SimulatePostTask(input)
-}
-
-// --- OpenCode Hook Runner ---
-
-// OpenCodeHookRunner executes OpenCode hooks in the test environment.
-type OpenCodeHookRunner struct {
- RepoDir string
- OpenCodeProjectDir string
- T interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
- }
-}
-
-// NewOpenCodeHookRunner creates a new OpenCode hook runner for the given repo directory.
-func NewOpenCodeHookRunner(repoDir, openCodeProjectDir string, t interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
-},
-) *OpenCodeHookRunner {
- return &OpenCodeHookRunner{
- RepoDir: repoDir,
- OpenCodeProjectDir: openCodeProjectDir,
- T: t,
- }
-}
-
-func (r *OpenCodeHookRunner) runOpenCodeHookWithInput(hookName string, input interface{}) error {
- r.T.Helper()
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- return fmt.Errorf("failed to marshal hook input: %w", err)
- }
-
- return r.runOpenCodeHookInRepoDir(hookName, inputJSON)
-}
-
-func (r *OpenCodeHookRunner) runOpenCodeHookInRepoDir(hookName string, inputJSON []byte) error {
- // Command structure: trace hooks opencode
- cmd := exec.Command(getTestBinary(), "hooks", "opencode", hookName)
- cmd.Dir = r.RepoDir
- cmd.Stdin = bytes.NewReader(inputJSON)
- cmd.Env = append(
- testutil.GitIsolatedEnv(),
- "TRACE_TEST_OPENCODE_PROJECT_DIR="+r.OpenCodeProjectDir,
- "TRACE_TEST_OPENCODE_MOCK_EXPORT=1", // Use pre-written mock transcript instead of calling opencode export
- )
-
- output, err := cmd.CombinedOutput()
- if err != nil {
- return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s",
- hookName, err, inputJSON, output)
- }
-
- r.T.Logf("OpenCode hook %s output: %s", hookName, output)
- return nil
-}
-
-// SimulateOpenCodeSessionStart simulates the session-start hook for OpenCode.
-// Note: The plugin now sends only session_id, not transcript_path.
-func (r *OpenCodeHookRunner) SimulateOpenCodeSessionStart(sessionID, _ string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- }
-
- return r.runOpenCodeHookWithInput("session-start", input)
-}
-
-// SimulateOpenCodeTurnStart simulates the turn-start hook for OpenCode.
-// This is equivalent to Claude Code's UserPromptSubmit.
-// Note: The plugin now sends only session_id and prompt, not transcript_path.
-func (r *OpenCodeHookRunner) SimulateOpenCodeTurnStart(sessionID, _, prompt string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- "prompt": prompt,
- }
-
- return r.runOpenCodeHookWithInput("turn-start", input)
-}
-
-// SimulateOpenCodeTurnEnd simulates the turn-end hook for OpenCode.
-// This is equivalent to Claude Code's Stop hook.
-// Note: The plugin now sends only session_id. The Go handler calls `opencode export`
-// to get the transcript. For tests, we write a mock export JSON file first.
-func (r *OpenCodeHookRunner) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error {
- r.T.Helper()
-
- // For integration tests, write the mock transcript to the location where the
- // lifecycle handler expects it (.trace/tmp/.json)
- if transcriptPath != "" {
- srcData, err := os.ReadFile(transcriptPath)
- if err != nil {
- r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to read transcript from %q: %v", transcriptPath, err)
- }
- destDir := filepath.Join(r.RepoDir, ".trace", "tmp")
- if err := os.MkdirAll(destDir, 0o755); err != nil {
- r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to create directory %q: %v", destDir, err)
- }
- destPath := filepath.Join(destDir, sessionID+".json")
- if err := os.WriteFile(destPath, srcData, 0o644); err != nil {
- r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to write transcript to %q: %v", destPath, err)
- }
- }
-
- input := map[string]string{
- "session_id": sessionID,
- }
-
- return r.runOpenCodeHookWithInput("turn-end", input)
-}
-
-// SimulateOpenCodeSessionEnd simulates the session-end hook for OpenCode.
-// Note: The plugin now sends only session_id, not transcript_path.
-func (r *OpenCodeHookRunner) SimulateOpenCodeSessionEnd(sessionID, _ string) error {
- r.T.Helper()
-
- input := map[string]string{
- "session_id": sessionID,
- }
-
- return r.runOpenCodeHookWithInput("session-end", input)
-}
-
-// OpenCodeSession represents a simulated OpenCode session.
-type OpenCodeSession struct {
- ID string // Raw session ID (e.g., "opencode-session-1")
- TranscriptPath string
- env *TestEnv
- msgCounter int
- // messages accumulates all messages across turns, matching real `opencode export`
- // behavior where each export returns the full session history.
- messages []map[string]interface{}
-}
-
-// NewOpenCodeSession creates a new simulated OpenCode session.
-func (env *TestEnv) NewOpenCodeSession() *OpenCodeSession {
- env.T.Helper()
-
- env.SessionCounter++
- sessionID := fmt.Sprintf("opencode-session-%d", env.SessionCounter)
- transcriptPath := filepath.Join(env.OpenCodeProjectDir, sessionID+".json")
-
- return &OpenCodeSession{
- ID: sessionID,
- TranscriptPath: transcriptPath,
- env: env,
- }
-}
-
-// CreateOpenCodeTranscript creates an OpenCode export JSON transcript file for the session.
-// Each call appends new messages to the accumulated session history, matching real
-// `opencode export` behavior where each export returns the full session history.
-func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []FileChange) string {
- // User message
- s.msgCounter++
- s.messages = append(s.messages, map[string]interface{}{
- "info": map[string]interface{}{
- "id": fmt.Sprintf("msg-%d", s.msgCounter),
- "role": "user",
- "time": map[string]interface{}{"created": 1708300000 + s.msgCounter},
- },
- "parts": []map[string]interface{}{
- {"type": "text", "text": prompt},
- },
- })
-
- // Assistant message with tool calls for file changes
- s.msgCounter++
- var parts []map[string]interface{}
- parts = append(parts, map[string]interface{}{
- "type": "text",
- "text": "I'll help you with that.",
- })
- for i, change := range changes {
- parts = append(parts, map[string]interface{}{
- "type": "tool",
- "tool": "write",
- "callID": fmt.Sprintf("call-%d", i+1),
- "state": map[string]interface{}{
- "status": "completed",
- "input": map[string]string{"filePath": change.Path},
- "output": "File written: " + change.Path,
- },
- })
- }
- parts = append(parts, map[string]interface{}{
- "type": "text",
- "text": "Done!",
- })
-
- s.messages = append(s.messages, map[string]interface{}{
- "info": map[string]interface{}{
- "id": fmt.Sprintf("msg-%d", s.msgCounter),
- "role": "assistant",
- "time": map[string]interface{}{
- "created": 1708300000 + s.msgCounter,
- "completed": 1708300000 + s.msgCounter + 5,
- },
- "tokens": map[string]interface{}{
- "input": 150,
- "output": 80,
- "reasoning": 10,
- "cache": map[string]int{"read": 5, "write": 15},
- },
- "cost": 0.003,
- },
- "parts": parts,
- })
-
- // Build export session format with accumulated messages
- exportSession := map[string]interface{}{
- "info": map[string]interface{}{
- "id": s.ID,
- },
- "messages": s.messages,
- }
-
- // Ensure directory exists
- if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil {
- s.env.T.Fatalf("failed to create transcript dir: %v", err)
- }
-
- // Write export JSON transcript
- data, err := json.MarshalIndent(exportSession, "", " ")
- if err != nil {
- s.env.T.Fatalf("failed to marshal transcript: %v", err)
- }
- if err := os.WriteFile(s.TranscriptPath, data, 0o644); err != nil {
- s.env.T.Fatalf("failed to write transcript: %v", err)
- }
-
- return s.TranscriptPath
-}
-
-// SimulateOpenCodeSessionStart is a convenience method on TestEnv.
-func (env *TestEnv) SimulateOpenCodeSessionStart(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
- return runner.SimulateOpenCodeSessionStart(sessionID, transcriptPath)
-}
-
-// SimulateOpenCodeTurnStart is a convenience method on TestEnv.
-func (env *TestEnv) SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt string) error {
- env.T.Helper()
- runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
- return runner.SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt)
-}
-
-// SimulateOpenCodeTurnEnd is a convenience method on TestEnv.
-func (env *TestEnv) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
- return runner.SimulateOpenCodeTurnEnd(sessionID, transcriptPath)
-}
-
-// SimulateOpenCodeSessionEnd is a convenience method on TestEnv.
-func (env *TestEnv) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string) error {
- env.T.Helper()
- runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
- return runner.SimulateOpenCodeSessionEnd(sessionID, transcriptPath)
-}
-
-// CopyTranscriptToTraceTmp copies an OpenCode transcript to .trace/tmp/.json.
-// This simulates what `opencode export` does in production. Required for mid-turn commits
-// where PrepareTranscript calls fetchAndCacheExport, which in mock mode expects the file
-// to already exist at .trace/tmp/.json.
-func (env *TestEnv) CopyTranscriptToTraceTmp(sessionID, transcriptPath string) {
- env.T.Helper()
-
- srcData, err := os.ReadFile(transcriptPath)
- if err != nil {
- env.T.Fatalf("CopyTranscriptToTraceTmp: failed to read transcript from %q: %v", transcriptPath, err)
- }
- destDir := filepath.Join(env.RepoDir, ".trace", "tmp")
- if err := os.MkdirAll(destDir, 0o755); err != nil {
- env.T.Fatalf("CopyTranscriptToTraceTmp: failed to create directory %q: %v", destDir, err)
- }
- destPath := filepath.Join(destDir, sessionID+".json")
- if err := os.WriteFile(destPath, srcData, 0o644); err != nil {
- env.T.Fatalf("CopyTranscriptToTraceTmp: failed to write transcript to %q: %v", destPath, err)
- }
-}
-
-// CodexHookRunner executes Codex CLI hooks in the test environment.
-type CodexHookRunner struct {
- RepoDir string
- T interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
- }
-}
-
-// NewCodexHookRunner creates a hook runner for Codex hooks in the given repo.
-func NewCodexHookRunner(repoDir string, t interface {
- Helper()
- Fatalf(format string, args ...interface{})
- Logf(format string, args ...interface{})
-},
-) *CodexHookRunner {
- return &CodexHookRunner{
- RepoDir: repoDir,
- T: t,
- }
-}
-
-// runCodexHook runs a Codex hook by name with the given JSON input via stdin.
-func (r *CodexHookRunner) runCodexHook(hookName string, input interface{}) error {
- r.T.Helper()
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- return fmt.Errorf("failed to marshal hook input: %w", err)
- }
-
- cmd := exec.Command(getTestBinary(), "hooks", "codex", hookName)
- cmd.Dir = r.RepoDir
- cmd.Stdin = bytes.NewReader(inputJSON)
- cmd.Env = testutil.GitIsolatedEnv()
-
- output, err := cmd.CombinedOutput()
- if err != nil {
- return fmt.Errorf("codex hook %s failed: %w\nInput: %s\nOutput: %s",
- hookName, err, inputJSON, output)
- }
-
- r.T.Logf("Codex hook %s output: %s", hookName, output)
- return nil
-}
-
-// SimulateCodexPostToolUseApplyPatch simulates a Codex PostToolUse hook
-// for an apply_patch tool invocation. The patch string is wrapped in the
-// Codex tool_input envelope before being dispatched.
-func (r *CodexHookRunner) SimulateCodexPostToolUseApplyPatch(sessionID, cwd, patch string) error {
- r.T.Helper()
-
- input := map[string]any{
- "session_id": sessionID,
- "turn_id": "t1",
- "transcript_path": nil,
- "cwd": cwd,
- "hook_event_name": "PostToolUse",
- "model": "gpt-5",
- "permission_mode": "default",
- "tool_name": "apply_patch",
- "tool_use_id": "call-patch",
- "tool_input": map[string]any{"patch": patch},
- "tool_response": "Patch applied successfully.",
- }
-
- return r.runCodexHook("post-tool-use", input)
-}
diff --git a/cli/integration_test/hooks_2.go b/cli/integration_test/hooks_2.go
new file mode 100644
index 0000000..4663ad4
--- /dev/null
+++ b/cli/integration_test/hooks_2.go
@@ -0,0 +1,752 @@
+//go:build integration
+
+package integration
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/GrayCodeAI/trace/cli/testutil"
+)
+
+// SimulateGeminiBeforeAgentWithOutput is a convenience method on TestEnv.
+func (env *TestEnv) SimulateGeminiBeforeAgentWithOutput(sessionID string) HookOutput {
+ env.T.Helper()
+ runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
+ return runner.SimulateGeminiBeforeAgentWithOutput(sessionID)
+}
+
+// SimulateGeminiAfterAgent is a convenience method on TestEnv.
+func (env *TestEnv) SimulateGeminiAfterAgent(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
+ return runner.SimulateGeminiAfterAgent(sessionID, transcriptPath)
+}
+
+// SimulateGeminiSessionEnd is a convenience method on TestEnv.
+func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T)
+ return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath)
+}
+
+// --- Factory AI Droid Hook Runner ---
+
+// FactoryDroidHookRunner executes Factory AI Droid hooks in the test environment.
+type FactoryDroidHookRunner struct {
+ RepoDir string
+ T interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+ }
+}
+
+// NewFactoryDroidHookRunner creates a new Factory Droid hook runner.
+func NewFactoryDroidHookRunner(repoDir string, t interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+},
+) *FactoryDroidHookRunner {
+ return &FactoryDroidHookRunner{
+ RepoDir: repoDir,
+ T: t,
+ }
+}
+
+// runDroidHookWithInput runs a Factory Droid hook with the given input.
+func (r *FactoryDroidHookRunner) runDroidHookWithInput(hookName string, input interface{}) error {
+ r.T.Helper()
+
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ return fmt.Errorf("failed to marshal hook input: %w", err)
+ }
+
+ return r.runDroidHookInRepoDir(hookName, inputJSON)
+}
+
+func (r *FactoryDroidHookRunner) runDroidHookInRepoDir(hookName string, inputJSON []byte) error {
+ cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName)
+ cmd.Dir = r.RepoDir
+ cmd.Stdin = bytes.NewReader(inputJSON)
+ cmd.Env = os.Environ()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s",
+ hookName, err, inputJSON, output)
+ }
+
+ r.T.Logf("Droid hook %s output: %s", hookName, output)
+ return nil
+}
+
+// runDroidHookWithOutput runs a Factory Droid hook and returns both stdout and stderr separately.
+func (r *FactoryDroidHookRunner) runDroidHookWithOutput(hookName string, inputJSON []byte) HookOutput {
+ cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName)
+ cmd.Dir = r.RepoDir
+ cmd.Stdin = bytes.NewReader(inputJSON)
+ cmd.Env = os.Environ()
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ return HookOutput{
+ Stdout: stdout.Bytes(),
+ Stderr: stderr.Bytes(),
+ Err: err,
+ }
+}
+
+// SimulateUserPromptSubmit simulates the UserPromptSubmit hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulateUserPromptSubmit(sessionID string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": "",
+ "prompt": "test prompt",
+ }
+
+ return r.runDroidHookWithInput("user-prompt-submit", input)
+}
+
+// SimulateUserPromptSubmitWithOutput simulates the UserPromptSubmit hook and returns the output.
+func (r *FactoryDroidHookRunner) SimulateUserPromptSubmitWithOutput(sessionID string) HookOutput {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": "",
+ "prompt": "test prompt",
+ }
+
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)}
+ }
+
+ return r.runDroidHookWithOutput("user-prompt-submit", inputJSON)
+}
+
+// SimulateStop simulates the Stop hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulateStop(sessionID, transcriptPath string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": transcriptPath,
+ }
+
+ return r.runDroidHookWithInput("stop", input)
+}
+
+// SimulateSessionStart simulates the SessionStart hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulateSessionStart(sessionID string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": "",
+ }
+
+ return r.runDroidHookWithInput("session-start", input)
+}
+
+// SimulateSessionStartWithOutput simulates the SessionStart hook and returns the output.
+func (r *FactoryDroidHookRunner) SimulateSessionStartWithOutput(sessionID string) HookOutput {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": "",
+ }
+
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)}
+ }
+
+ return r.runDroidHookWithOutput("session-start", inputJSON)
+}
+
+// SimulateSessionEnd simulates the SessionEnd hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulateSessionEnd(sessionID, transcriptPath string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "transcript_path": transcriptPath,
+ }
+
+ return r.runDroidHookWithInput("session-end", input)
+}
+
+// SimulatePreTask simulates the PreToolUse[Task] hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulatePreTask(sessionID, transcriptPath, toolUseID string) error {
+ r.T.Helper()
+
+ input := map[string]interface{}{
+ "session_id": sessionID,
+ "transcript_path": transcriptPath,
+ "tool_use_id": toolUseID,
+ "tool_input": map[string]string{
+ "subagent_type": "general-purpose",
+ "description": "test task",
+ },
+ }
+
+ return r.runDroidHookWithInput("pre-tool-use", input)
+}
+
+// SimulatePostTask simulates the PostToolUse[Task] hook for Factory Droid.
+func (r *FactoryDroidHookRunner) SimulatePostTask(input PostTaskInput) error {
+ r.T.Helper()
+
+ hookInput := map[string]interface{}{
+ "session_id": input.SessionID,
+ "transcript_path": input.TranscriptPath,
+ "tool_use_id": input.ToolUseID,
+ "tool_input": map[string]string{},
+ "tool_response": map[string]string{
+ "agentId": input.AgentID,
+ },
+ }
+
+ return r.runDroidHookWithInput("post-tool-use", hookInput)
+}
+
+// FactoryDroidSession represents a simulated Factory AI Droid session.
+type FactoryDroidSession struct {
+ ID string
+ TranscriptPath string
+ env *TestEnv
+}
+
+// NewFactoryDroidSession creates a new simulated Factory Droid session.
+func (env *TestEnv) NewFactoryDroidSession() *FactoryDroidSession {
+ env.T.Helper()
+
+ env.SessionCounter++
+ sessionID := fmt.Sprintf("droid-session-%d", env.SessionCounter)
+ transcriptPath := filepath.Join(env.RepoDir, ".trace", "tmp", sessionID+".jsonl")
+
+ return &FactoryDroidSession{
+ ID: sessionID,
+ TranscriptPath: transcriptPath,
+ env: env,
+ }
+}
+
+// CreateDroidTranscript creates a Droid-envelope JSONL transcript file.
+// Droid wraps messages as {"type":"message","id":"...","message":{"role":"...","content":[...]}},
+// unlike Claude Code which uses {"type":"assistant","uuid":"...","message":{"content":[...]}}.
+func (s *FactoryDroidSession) CreateDroidTranscript(prompt string, changes []FileChange) string {
+ var lines []map[string]interface{}
+
+ // User message with prompt
+ lines = append(lines, map[string]interface{}{
+ "type": "message",
+ "id": "m1",
+ "message": map[string]interface{}{
+ "role": "user",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": prompt},
+ },
+ },
+ })
+
+ // Assistant message with tool uses
+ assistantContent := []interface{}{
+ map[string]interface{}{"type": "text", "text": "I'll help you with that."},
+ }
+ for i, change := range changes {
+ assistantContent = append(assistantContent, map[string]interface{}{
+ "type": "tool_use",
+ "id": fmt.Sprintf("toolu_%d", i+1),
+ "name": "Write",
+ "input": map[string]string{"file_path": change.Path, "content": change.Content},
+ })
+ }
+ lines = append(lines, map[string]interface{}{
+ "type": "message",
+ "id": "m2",
+ "message": map[string]interface{}{
+ "role": "assistant",
+ "content": assistantContent,
+ },
+ })
+
+ // Tool results
+ toolResultContent := make([]map[string]interface{}, 0, len(changes))
+ for i := range changes {
+ toolResultContent = append(toolResultContent, map[string]interface{}{
+ "type": "tool_result",
+ "tool_use_id": fmt.Sprintf("toolu_%d", i+1),
+ "content": "Success",
+ })
+ }
+ lines = append(lines, map[string]interface{}{
+ "type": "message",
+ "id": "m3",
+ "message": map[string]interface{}{
+ "role": "user",
+ "content": toolResultContent,
+ },
+ })
+
+ // Final assistant message
+ lines = append(lines, map[string]interface{}{
+ "type": "message",
+ "id": "m4",
+ "message": map[string]interface{}{
+ "role": "assistant",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": "Done!"},
+ },
+ },
+ })
+
+ // Ensure directory exists
+ if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil {
+ s.env.T.Fatalf("failed to create transcript dir: %v", err)
+ }
+
+ // Write as JSONL
+ file, err := os.Create(s.TranscriptPath)
+ if err != nil {
+ s.env.T.Fatalf("failed to create transcript file: %v", err)
+ }
+ defer func() { _ = file.Close() }()
+
+ encoder := json.NewEncoder(file)
+ for _, line := range lines {
+ if err := encoder.Encode(line); err != nil {
+ s.env.T.Fatalf("failed to encode transcript line: %v", err)
+ }
+ }
+
+ return s.TranscriptPath
+}
+
+// SimulateFactoryDroidUserPromptSubmit is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidUserPromptSubmit(sessionID string) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateUserPromptSubmit(sessionID)
+}
+
+// SimulateFactoryDroidUserPromptSubmitWithOutput is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidUserPromptSubmitWithOutput(sessionID string) HookOutput {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateUserPromptSubmitWithOutput(sessionID)
+}
+
+// SimulateFactoryDroidStop is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidStop(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateStop(sessionID, transcriptPath)
+}
+
+// SimulateFactoryDroidSessionStart is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidSessionStart(sessionID string) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateSessionStart(sessionID)
+}
+
+// SimulateFactoryDroidSessionStartWithOutput is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidSessionStartWithOutput(sessionID string) HookOutput {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateSessionStartWithOutput(sessionID)
+}
+
+// SimulateFactoryDroidSessionEnd is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidSessionEnd(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulateSessionEnd(sessionID, transcriptPath)
+}
+
+// SimulateFactoryDroidPreTask is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidPreTask(sessionID, transcriptPath, toolUseID string) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulatePreTask(sessionID, transcriptPath, toolUseID)
+}
+
+// SimulateFactoryDroidPostTask is a convenience method on TestEnv.
+func (env *TestEnv) SimulateFactoryDroidPostTask(input PostTaskInput) error {
+ env.T.Helper()
+ runner := NewFactoryDroidHookRunner(env.RepoDir, env.T)
+ return runner.SimulatePostTask(input)
+}
+
+// --- OpenCode Hook Runner ---
+
+// OpenCodeHookRunner executes OpenCode hooks in the test environment.
+type OpenCodeHookRunner struct {
+ RepoDir string
+ OpenCodeProjectDir string
+ T interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+ }
+}
+
+// NewOpenCodeHookRunner creates a new OpenCode hook runner for the given repo directory.
+func NewOpenCodeHookRunner(repoDir, openCodeProjectDir string, t interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+},
+) *OpenCodeHookRunner {
+ return &OpenCodeHookRunner{
+ RepoDir: repoDir,
+ OpenCodeProjectDir: openCodeProjectDir,
+ T: t,
+ }
+}
+
+func (r *OpenCodeHookRunner) runOpenCodeHookWithInput(hookName string, input interface{}) error {
+ r.T.Helper()
+
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ return fmt.Errorf("failed to marshal hook input: %w", err)
+ }
+
+ return r.runOpenCodeHookInRepoDir(hookName, inputJSON)
+}
+
+func (r *OpenCodeHookRunner) runOpenCodeHookInRepoDir(hookName string, inputJSON []byte) error {
+ // Command structure: trace hooks opencode
+ cmd := exec.Command(getTestBinary(), "hooks", "opencode", hookName)
+ cmd.Dir = r.RepoDir
+ cmd.Stdin = bytes.NewReader(inputJSON)
+ cmd.Env = append(
+ testutil.GitIsolatedEnv(),
+ "TRACE_TEST_OPENCODE_PROJECT_DIR="+r.OpenCodeProjectDir,
+ "TRACE_TEST_OPENCODE_MOCK_EXPORT=1", // Use pre-written mock transcript instead of calling opencode export
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s",
+ hookName, err, inputJSON, output)
+ }
+
+ r.T.Logf("OpenCode hook %s output: %s", hookName, output)
+ return nil
+}
+
+// SimulateOpenCodeSessionStart simulates the session-start hook for OpenCode.
+// Note: The plugin now sends only session_id, not transcript_path.
+func (r *OpenCodeHookRunner) SimulateOpenCodeSessionStart(sessionID, _ string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ }
+
+ return r.runOpenCodeHookWithInput("session-start", input)
+}
+
+// SimulateOpenCodeTurnStart simulates the turn-start hook for OpenCode.
+// This is equivalent to Claude Code's UserPromptSubmit.
+// Note: The plugin now sends only session_id and prompt, not transcript_path.
+func (r *OpenCodeHookRunner) SimulateOpenCodeTurnStart(sessionID, _, prompt string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ "prompt": prompt,
+ }
+
+ return r.runOpenCodeHookWithInput("turn-start", input)
+}
+
+// SimulateOpenCodeTurnEnd simulates the turn-end hook for OpenCode.
+// This is equivalent to Claude Code's Stop hook.
+// Note: The plugin now sends only session_id. The Go handler calls `opencode export`
+// to get the transcript. For tests, we write a mock export JSON file first.
+func (r *OpenCodeHookRunner) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error {
+ r.T.Helper()
+
+ // For integration tests, write the mock transcript to the location where the
+ // lifecycle handler expects it (.trace/tmp/.json)
+ if transcriptPath != "" {
+ srcData, err := os.ReadFile(transcriptPath)
+ if err != nil {
+ r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to read transcript from %q: %v", transcriptPath, err)
+ }
+ destDir := filepath.Join(r.RepoDir, ".trace", "tmp")
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to create directory %q: %v", destDir, err)
+ }
+ destPath := filepath.Join(destDir, sessionID+".json")
+ if err := os.WriteFile(destPath, srcData, 0o644); err != nil {
+ r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to write transcript to %q: %v", destPath, err)
+ }
+ }
+
+ input := map[string]string{
+ "session_id": sessionID,
+ }
+
+ return r.runOpenCodeHookWithInput("turn-end", input)
+}
+
+// SimulateOpenCodeSessionEnd simulates the session-end hook for OpenCode.
+// Note: The plugin now sends only session_id, not transcript_path.
+func (r *OpenCodeHookRunner) SimulateOpenCodeSessionEnd(sessionID, _ string) error {
+ r.T.Helper()
+
+ input := map[string]string{
+ "session_id": sessionID,
+ }
+
+ return r.runOpenCodeHookWithInput("session-end", input)
+}
+
+// OpenCodeSession represents a simulated OpenCode session.
+type OpenCodeSession struct {
+ ID string // Raw session ID (e.g., "opencode-session-1")
+ TranscriptPath string
+ env *TestEnv
+ msgCounter int
+ // messages accumulates all messages across turns, matching real `opencode export`
+ // behavior where each export returns the full session history.
+ messages []map[string]interface{}
+}
+
+// NewOpenCodeSession creates a new simulated OpenCode session.
+func (env *TestEnv) NewOpenCodeSession() *OpenCodeSession {
+ env.T.Helper()
+
+ env.SessionCounter++
+ sessionID := fmt.Sprintf("opencode-session-%d", env.SessionCounter)
+ transcriptPath := filepath.Join(env.OpenCodeProjectDir, sessionID+".json")
+
+ return &OpenCodeSession{
+ ID: sessionID,
+ TranscriptPath: transcriptPath,
+ env: env,
+ }
+}
+
+// CreateOpenCodeTranscript creates an OpenCode export JSON transcript file for the session.
+// Each call appends new messages to the accumulated session history, matching real
+// `opencode export` behavior where each export returns the full session history.
+func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []FileChange) string {
+ // User message
+ s.msgCounter++
+ s.messages = append(s.messages, map[string]interface{}{
+ "info": map[string]interface{}{
+ "id": fmt.Sprintf("msg-%d", s.msgCounter),
+ "role": "user",
+ "time": map[string]interface{}{"created": 1708300000 + s.msgCounter},
+ },
+ "parts": []map[string]interface{}{
+ {"type": "text", "text": prompt},
+ },
+ })
+
+ // Assistant message with tool calls for file changes
+ s.msgCounter++
+ var parts []map[string]interface{}
+ parts = append(parts, map[string]interface{}{
+ "type": "text",
+ "text": "I'll help you with that.",
+ })
+ for i, change := range changes {
+ parts = append(parts, map[string]interface{}{
+ "type": "tool",
+ "tool": "write",
+ "callID": fmt.Sprintf("call-%d", i+1),
+ "state": map[string]interface{}{
+ "status": "completed",
+ "input": map[string]string{"filePath": change.Path},
+ "output": "File written: " + change.Path,
+ },
+ })
+ }
+ parts = append(parts, map[string]interface{}{
+ "type": "text",
+ "text": "Done!",
+ })
+
+ s.messages = append(s.messages, map[string]interface{}{
+ "info": map[string]interface{}{
+ "id": fmt.Sprintf("msg-%d", s.msgCounter),
+ "role": "assistant",
+ "time": map[string]interface{}{
+ "created": 1708300000 + s.msgCounter,
+ "completed": 1708300000 + s.msgCounter + 5,
+ },
+ "tokens": map[string]interface{}{
+ "input": 150,
+ "output": 80,
+ "reasoning": 10,
+ "cache": map[string]int{"read": 5, "write": 15},
+ },
+ "cost": 0.003,
+ },
+ "parts": parts,
+ })
+
+ // Build export session format with accumulated messages
+ exportSession := map[string]interface{}{
+ "info": map[string]interface{}{
+ "id": s.ID,
+ },
+ "messages": s.messages,
+ }
+
+ // Ensure directory exists
+ if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil {
+ s.env.T.Fatalf("failed to create transcript dir: %v", err)
+ }
+
+ // Write export JSON transcript
+ data, err := json.MarshalIndent(exportSession, "", " ")
+ if err != nil {
+ s.env.T.Fatalf("failed to marshal transcript: %v", err)
+ }
+ if err := os.WriteFile(s.TranscriptPath, data, 0o644); err != nil {
+ s.env.T.Fatalf("failed to write transcript: %v", err)
+ }
+
+ return s.TranscriptPath
+}
+
+// SimulateOpenCodeSessionStart is a convenience method on TestEnv.
+func (env *TestEnv) SimulateOpenCodeSessionStart(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
+ return runner.SimulateOpenCodeSessionStart(sessionID, transcriptPath)
+}
+
+// SimulateOpenCodeTurnStart is a convenience method on TestEnv.
+func (env *TestEnv) SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt string) error {
+ env.T.Helper()
+ runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
+ return runner.SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt)
+}
+
+// SimulateOpenCodeTurnEnd is a convenience method on TestEnv.
+func (env *TestEnv) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
+ return runner.SimulateOpenCodeTurnEnd(sessionID, transcriptPath)
+}
+
+// SimulateOpenCodeSessionEnd is a convenience method on TestEnv.
+func (env *TestEnv) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string) error {
+ env.T.Helper()
+ runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
+ return runner.SimulateOpenCodeSessionEnd(sessionID, transcriptPath)
+}
+
+// CopyTranscriptToTraceTmp copies an OpenCode transcript to .trace/tmp/.json.
+// This simulates what `opencode export` does in production. Required for mid-turn commits
+// where PrepareTranscript calls fetchAndCacheExport, which in mock mode expects the file
+// to already exist at .trace/tmp/.json.
+func (env *TestEnv) CopyTranscriptToTraceTmp(sessionID, transcriptPath string) {
+ env.T.Helper()
+
+ srcData, err := os.ReadFile(transcriptPath)
+ if err != nil {
+ env.T.Fatalf("CopyTranscriptToTraceTmp: failed to read transcript from %q: %v", transcriptPath, err)
+ }
+ destDir := filepath.Join(env.RepoDir, ".trace", "tmp")
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ env.T.Fatalf("CopyTranscriptToTraceTmp: failed to create directory %q: %v", destDir, err)
+ }
+ destPath := filepath.Join(destDir, sessionID+".json")
+ if err := os.WriteFile(destPath, srcData, 0o644); err != nil {
+ env.T.Fatalf("CopyTranscriptToTraceTmp: failed to write transcript to %q: %v", destPath, err)
+ }
+}
+
+// CodexHookRunner executes Codex CLI hooks in the test environment.
+type CodexHookRunner struct {
+ RepoDir string
+ T interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+ }
+}
+
+// NewCodexHookRunner creates a hook runner for Codex hooks in the given repo.
+func NewCodexHookRunner(repoDir string, t interface {
+ Helper()
+ Fatalf(format string, args ...interface{})
+ Logf(format string, args ...interface{})
+},
+) *CodexHookRunner {
+ return &CodexHookRunner{
+ RepoDir: repoDir,
+ T: t,
+ }
+}
+
+// runCodexHook runs a Codex hook by name with the given JSON input via stdin.
+func (r *CodexHookRunner) runCodexHook(hookName string, input interface{}) error {
+ r.T.Helper()
+
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ return fmt.Errorf("failed to marshal hook input: %w", err)
+ }
+
+ cmd := exec.Command(getTestBinary(), "hooks", "codex", hookName)
+ cmd.Dir = r.RepoDir
+ cmd.Stdin = bytes.NewReader(inputJSON)
+ cmd.Env = testutil.GitIsolatedEnv()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("codex hook %s failed: %w\nInput: %s\nOutput: %s",
+ hookName, err, inputJSON, output)
+ }
+
+ r.T.Logf("Codex hook %s output: %s", hookName, output)
+ return nil
+}
+
+// SimulateCodexPostToolUseApplyPatch simulates a Codex PostToolUse hook
+// for an apply_patch tool invocation. The patch string is wrapped in the
+// Codex tool_input envelope before being dispatched.
+func (r *CodexHookRunner) SimulateCodexPostToolUseApplyPatch(sessionID, cwd, patch string) error {
+ r.T.Helper()
+
+ input := map[string]any{
+ "session_id": sessionID,
+ "turn_id": "t1",
+ "transcript_path": nil,
+ "cwd": cwd,
+ "hook_event_name": "PostToolUse",
+ "model": "gpt-5",
+ "permission_mode": "default",
+ "tool_name": "apply_patch",
+ "tool_use_id": "call-patch",
+ "tool_input": map[string]any{"patch": patch},
+ "tool_response": "Patch applied successfully.",
+ }
+
+ return r.runCodexHook("post-tool-use", input)
+}
diff --git a/cli/integration_test/manual_commit_workflow_2_test.go b/cli/integration_test/manual_commit_workflow_2_test.go
new file mode 100644
index 0000000..c3e6750
--- /dev/null
+++ b/cli/integration_test/manual_commit_workflow_2_test.go
@@ -0,0 +1,682 @@
+//go:build integration
+
+package integration
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/paths"
+)
+
+// TestShadow_FullTranscriptContext verifies that each checkpoint includes
+// only the prompts from its checkpoint portion, not the trace session.
+//
+// This tests checkpoint-scoped prompts:
+// - First commit: prompt.txt includes prompts 1-2 (from checkpoint start)
+// - Second commit: prompt.txt includes only prompt 3 (from second checkpoint start)
+func TestShadow_FullTranscriptContext(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/incremental")
+ env.InitTrace()
+
+ t.Log("Phase 1: First session with two prompts")
+
+ // Start first session
+ session1 := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create function A in a.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // First prompt: create file A
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ // Build transcript with first prompt
+ session1.TranscriptBuilder.AddUserMessage("Create function A in a.go")
+ session1.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
+ toolID1 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session1.TranscriptBuilder.AddToolResult(toolID1)
+ session1.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
+
+ // Second prompt in same session: create file B
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Now create function B in b.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (second prompt) failed: %v", err)
+ }
+ fileBContent := "package main\n\nfunc B() {}\n"
+ env.WriteFile("b.go", fileBContent)
+
+ session1.TranscriptBuilder.AddUserMessage("Now create function B in b.go")
+ session1.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.")
+ toolID2 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
+ session1.TranscriptBuilder.AddToolResult(toolID2)
+ session1.TranscriptBuilder.AddAssistantMessage("Done creating function B!")
+
+ // Write transcript
+ if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ // Save checkpoint (triggers SaveStep)
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ t.Log("Phase 2: First user commit")
+
+ // User commits
+ env.GitCommitWithShadowHooks("Add functions A and B", "a.go", "b.go")
+
+ // Get first checkpoint ID from commit message trailer
+ commit1Hash := env.GetHeadHash()
+ checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
+ t.Logf("First checkpoint ID: %s", checkpoint1ID)
+
+ // Verify first checkpoint has both prompts (uses session file path in numbered subdirectory)
+ promptPath1 := SessionFilePath(checkpoint1ID, "prompt.txt")
+ prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath1)
+ if !found {
+ t.Errorf("prompt.txt should exist at %s", promptPath1)
+ } else {
+ t.Logf("First prompt.txt content:\n%s", prompt1Content)
+ // Should contain both "Create function A" and "create function B"
+ if !strings.Contains(prompt1Content, "Create function A") {
+ t.Error("First prompt.txt should contain 'Create function A'")
+ }
+ if !strings.Contains(prompt1Content, "create function B") {
+ t.Error("First prompt.txt should contain 'create function B'")
+ }
+ }
+
+ t.Log("Phase 3: Continue session with third prompt")
+
+ // Continue the session with a new prompt
+ // First, simulate another user prompt submit to track the new base
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Finally, create function C in c.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (continued) failed: %v", err)
+ }
+
+ // Third prompt: create file C
+ fileCContent := "package main\n\nfunc C() {}\n"
+ env.WriteFile("c.go", fileCContent)
+
+ // Add to transcript (continuing from previous)
+ session1.TranscriptBuilder.AddUserMessage("Finally, create function C in c.go")
+ session1.TranscriptBuilder.AddAssistantMessage("I'll create function C for you.")
+ toolID3 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "c.go", fileCContent)
+ session1.TranscriptBuilder.AddToolResult(toolID3)
+ session1.TranscriptBuilder.AddAssistantMessage("Done creating function C!")
+
+ // Write updated transcript
+ if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write updated transcript: %v", err)
+ }
+
+ // Save checkpoint
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (second) failed: %v", err)
+ }
+
+ t.Log("Phase 4: Second user commit")
+
+ // User commits again
+ env.GitCommitWithShadowHooks("Add function C", "c.go")
+
+ // Get second checkpoint ID from commit message trailer
+ commit2Hash := env.GetHeadHash()
+ checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
+ t.Logf("Second checkpoint ID: %s", checkpoint2ID)
+
+ // Verify different checkpoint IDs
+ if checkpoint1ID == checkpoint2ID {
+ t.Errorf("Second commit should have different checkpoint ID: %s vs %s", checkpoint1ID, checkpoint2ID)
+ }
+
+ t.Log("Phase 5: Verify full transcript preserved in second checkpoint")
+
+ // Verify second checkpoint has the FULL transcript (all three prompts)
+ // Session files are now in numbered subdirectories (e.g., 0/prompt.txt)
+ promptPath2 := SessionFilePath(checkpoint2ID, "prompt.txt")
+ prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath2)
+ if !found {
+ t.Errorf("prompt.txt should exist at %s", promptPath2)
+ } else {
+ t.Logf("Second prompt.txt content:\n%s", prompt2Content)
+
+ // Should contain only the checkpoint-scoped prompt (third prompt only)
+ if !strings.Contains(prompt2Content, "create function C") {
+ t.Error("Second prompt.txt should contain 'create function C'")
+ }
+ }
+
+ t.Log("Shadow full transcript context test completed successfully!")
+}
+
+// TestShadow_RewindAndCondensation verifies that after rewinding to an earlier
+// checkpoint, the checkpoint only includes prompts up to that point.
+//
+// Workflow:
+// 1. Create checkpoint 1 (prompt 1)
+// 2. Create checkpoint 2 (prompt 2)
+// 3. Rewind to checkpoint 1
+// 4. User commits
+// 5. Verify checkpoint only contains prompt 1 (NOT prompt 2)
+func TestShadow_RewindAndCondensation(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/rewind-test")
+ env.InitTrace()
+
+ t.Log("Phase 1: Create first checkpoint with prompt 1")
+
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A in a.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // First prompt: create file A
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function A in a.go")
+ session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
+ toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session.TranscriptBuilder.AddToolResult(toolID1)
+ session.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 1) failed: %v", err)
+ }
+
+ // Get checkpoint 1 for later
+ rewindPoints := env.GetRewindPoints()
+ if len(rewindPoints) != 1 {
+ t.Fatalf("Expected 1 rewind point after checkpoint 1, got %d", len(rewindPoints))
+ }
+ checkpoint1 := rewindPoints[0]
+ t.Logf("Checkpoint 1: %s - %s", checkpoint1.ID[:7], checkpoint1.Message)
+
+ t.Log("Phase 2: Create second checkpoint with prompt 2")
+
+ // Second prompt: modify file A (a different approach)
+ fileAModified := "package main\n\nfunc A() {\n\t// Modified version\n}\n"
+ env.WriteFile("a.go", fileAModified)
+
+ session.TranscriptBuilder.AddUserMessage("Actually, modify function A to have a comment")
+ session.TranscriptBuilder.AddAssistantMessage("I'll modify function A for you.")
+ toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAModified)
+ session.TranscriptBuilder.AddToolResult(toolID2)
+ session.TranscriptBuilder.AddAssistantMessage("Done modifying function A!")
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
+ }
+
+ rewindPoints = env.GetRewindPoints()
+ if len(rewindPoints) != 2 {
+ t.Fatalf("Expected 2 rewind points after checkpoint 2, got %d", len(rewindPoints))
+ }
+ t.Logf("Checkpoint 2: %s - %s", rewindPoints[0].ID[:7], rewindPoints[0].Message)
+
+ // Verify file has modified content
+ currentContent := env.ReadFile("a.go")
+ if currentContent != fileAModified {
+ t.Errorf("a.go should have modified content before rewind")
+ }
+
+ t.Log("Phase 3: Rewind to checkpoint 1")
+
+ // Rewind using the CLI (which calls the strategy internally)
+ if err := env.Rewind(checkpoint1.ID); err != nil {
+ t.Fatalf("Rewind failed: %v", err)
+ }
+
+ // Verify file content is restored to checkpoint 1
+ restoredContent := env.ReadFile("a.go")
+ if restoredContent != fileAContent {
+ t.Errorf("a.go should have original content after rewind.\nExpected:\n%s\nGot:\n%s", fileAContent, restoredContent)
+ }
+ t.Log("Files successfully restored to checkpoint 1")
+
+ t.Log("Phase 4: User commits after rewind")
+
+ // User commits - this should trigger condensation
+ env.GitCommitWithShadowHooks("Add function A (reverted)", "a.go")
+
+ // Get checkpoint ID from commit message trailer
+ commitHash := env.GetHeadHash()
+ checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash)
+ t.Logf("Checkpoint ID: %s", checkpointID)
+
+ t.Log("Phase 5: Verify checkpoint only contains prompt 1")
+
+ // Check prompt.txt (uses session file path in numbered subdirectory)
+ promptPath := SessionFilePath(checkpointID, "prompt.txt")
+ promptContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath)
+ if !found {
+ t.Errorf("prompt.txt should exist at %s", promptPath)
+ } else {
+ t.Logf("prompt.txt content:\n%s", promptContent)
+
+ // Should contain prompt 1
+ if !strings.Contains(promptContent, "Create function A") {
+ t.Error("prompt.txt should contain 'Create function A' from checkpoint 1")
+ }
+
+ // Should NOT contain prompt 2 (because we rewound past it)
+ if strings.Contains(promptContent, "modify function A") {
+ t.Error("prompt.txt should NOT contain 'modify function A' - we rewound past that checkpoint")
+ }
+ }
+
+ t.Log("Shadow rewind and condensation test completed successfully!")
+}
+
+// TestShadow_RewindPreservesUntrackedFilesFromSessionStart tests that files that existed
+// in the working directory (but weren't tracked in git) before the session started are
+// preserved when rewinding. This was a bug where such files were incorrectly deleted.
+func TestShadow_RewindPreservesUntrackedFilesFromSessionStart(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository with initial commit
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/untracked-test")
+
+ // Create an untracked file BEFORE initializing Trace session
+ // This simulates files like .claude/settings.json created by "trace setup"
+ untrackedContent := `{"key": "value"}`
+ env.WriteFile(".claude/settings.json", untrackedContent)
+
+ // Initialize Trace with manual-commit strategy
+ env.InitTrace()
+
+ t.Log("Phase 1: Create first checkpoint")
+
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // First prompt: create file A
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function A")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session.TranscriptBuilder.AddToolResult(toolID1)
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 1) failed: %v", err)
+ }
+
+ rewindPoints := env.GetRewindPoints()
+ if len(rewindPoints) != 1 {
+ t.Fatalf("Expected 1 rewind point, got %d", len(rewindPoints))
+ }
+ checkpoint1 := rewindPoints[0]
+ t.Logf("Checkpoint 1: %s", checkpoint1.ID[:7])
+
+ t.Log("Phase 2: Create second checkpoint")
+
+ // Second prompt: create file B
+ fileBContent := "package main\n\nfunc B() {}\n"
+ env.WriteFile("b.go", fileBContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function B")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
+ session.TranscriptBuilder.AddToolResult(toolID2)
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
+ }
+
+ rewindPoints = env.GetRewindPoints()
+ if len(rewindPoints) != 2 {
+ t.Fatalf("Expected 2 rewind points, got %d", len(rewindPoints))
+ }
+ t.Logf("Checkpoint 2: %s", rewindPoints[0].ID[:7])
+
+ // Verify the untracked file still exists before rewind
+ if !env.FileExists(".claude/settings.json") {
+ t.Fatal("Untracked file .claude/settings.json should exist before rewind")
+ }
+
+ t.Log("Phase 3: Rewind to checkpoint 1")
+
+ if err := env.Rewind(checkpoint1.ID); err != nil {
+ t.Fatalf("Rewind failed: %v", err)
+ }
+
+ // Verify that the untracked file that existed before session start is PRESERVED
+ if !env.FileExists(".claude/settings.json") {
+ t.Error("CRITICAL: .claude/settings.json was deleted during rewind but it existed before the session started!")
+ } else {
+ restoredContent := env.ReadFile(".claude/settings.json")
+ if restoredContent != untrackedContent {
+ t.Errorf("Untracked file content changed.\nExpected:\n%s\nGot:\n%s", untrackedContent, restoredContent)
+ } else {
+ t.Log("✓ Untracked file .claude/settings.json was preserved correctly")
+ }
+ }
+
+ // Verify b.go was deleted (it was created after checkpoint 1)
+ if env.FileExists("b.go") {
+ t.Error("b.go should have been deleted during rewind (it was created after checkpoint 1)")
+ } else {
+ t.Log("✓ b.go was correctly deleted during rewind")
+ }
+
+ // Verify a.go was restored
+ if !env.FileExists("a.go") {
+ t.Error("a.go should exist after rewind to checkpoint 1")
+ } else {
+ restoredA := env.ReadFile("a.go")
+ if restoredA != fileAContent {
+ t.Errorf("a.go content incorrect after rewind")
+ } else {
+ t.Log("✓ a.go was correctly restored")
+ }
+ }
+
+ t.Log("Test completed successfully!")
+}
+
+// TestShadow_IntermediateCommitsWithoutPrompts tests that commits without new Claude
+// content do NOT get checkpoint trailers.
+//
+// Scenario:
+// 1. Session starts, work happens, checkpoint created
+// 2. First commit gets a trailer (has new content)
+// 3. User commits unrelated files without new Claude work - NO trailer (no new content)
+// 4. User enters new prompt, creates more files
+// 5. Second commit with Claude content gets a trailer
+func TestShadow_IntermediateCommitsWithoutPrompts(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/intermediate-commits")
+ env.InitTrace()
+
+ t.Log("Phase 1: Start session and create checkpoint")
+
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A in a.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // First prompt: create file A
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function A in a.go")
+ session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
+ toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session.TranscriptBuilder.AddToolResult(toolID1)
+ session.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ t.Log("Phase 2: First commit (with session content)")
+
+ env.GitCommitWithShadowHooks("Add function A", "a.go")
+ commit1Hash := env.GetHeadHash()
+ checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
+ t.Logf("First commit: %s, checkpoint from trailer: %s", commit1Hash[:7], checkpoint1ID)
+ t.Logf("First commit message:\n%s", env.GetCommitMessage(commit1Hash))
+
+ if checkpoint1ID == "" {
+ t.Fatal("First commit should have a checkpoint ID in its trailer (has new content)")
+ }
+
+ t.Log("Phase 3: Create unrelated file and commit WITHOUT new prompt")
+
+ // User creates an unrelated file and commits without entering a new Claude prompt
+ // Since there's no new session content, this commit should NOT get a trailer
+ env.WriteFile("unrelated.txt", "This is an unrelated file")
+ env.GitCommitWithShadowHooks("Add unrelated file", "unrelated.txt")
+
+ commit2Hash := env.GetHeadHash()
+ checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
+ t.Logf("Second commit: %s, checkpoint from trailer: %s", commit2Hash[:7], checkpoint2ID)
+ t.Logf("Second commit message:\n%s", env.GetCommitMessage(commit2Hash))
+
+ // Second commit should NOT get a checkpoint ID (no new session content)
+ if checkpoint2ID != "" {
+ t.Errorf("Second commit should NOT have a checkpoint trailer (no new content), got: %s", checkpoint2ID)
+ }
+
+ t.Log("Phase 4: New Claude work and commit")
+
+ // Now user enters new prompt and does more work
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B in b.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ fileBContent := "package main\n\nfunc B() {}\n"
+ env.WriteFile("b.go", fileBContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function B in b.go")
+ session.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.")
+ toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
+ session.TranscriptBuilder.AddToolResult(toolID2)
+ session.TranscriptBuilder.AddAssistantMessage("Done creating function B!")
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ env.GitCommitWithShadowHooks("Add function B", "b.go")
+
+ commit3Hash := env.GetHeadHash()
+ checkpoint3ID := env.GetCheckpointIDFromCommitMessage(commit3Hash)
+ t.Logf("Third commit: %s, checkpoint from trailer: %s", commit3Hash[:7], checkpoint3ID)
+ t.Logf("Third commit message:\n%s", env.GetCommitMessage(commit3Hash))
+
+ if checkpoint3ID == "" {
+ t.Fatal("Third commit should have a checkpoint ID (has new content)")
+ }
+
+ // First and third checkpoint IDs should be different
+ if checkpoint1ID == checkpoint3ID {
+ t.Errorf("First and third commits should have different checkpoint IDs: %s vs %s",
+ checkpoint1ID, checkpoint3ID)
+ }
+
+ t.Log("Phase 5: Verify checkpoints exist in trace/checkpoints/v1")
+
+ for _, cpID := range []string{checkpoint1ID, checkpoint3ID} {
+ shardedPath := ShardedCheckpointPath(cpID)
+ metadataPath := shardedPath + "/metadata.json"
+ if !env.FileExistsInBranch(paths.MetadataBranchName, metadataPath) {
+ t.Errorf("Checkpoint %s should have metadata.json at %s", cpID, metadataPath)
+ }
+ }
+
+ t.Log("Intermediate commits test completed successfully!")
+}
+
+// TestShadow_FullTranscriptCondensationWithIntermediateCommits tests that checkpoints
+// contain only checkpoint-scoped prompts across multiple commits.
+//
+// Scenario:
+// 1. Session with prompts A and B, commit 1 → prompt.txt has A and B
+// 2. Continue session with prompt C, commit 2 (without intermediate prompt submit)
+// 3. Verify commit 2's prompt.txt has only C (checkpoint-scoped)
+func TestShadow_FullTranscriptCondensationWithIntermediateCommits(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/incremental-intermediate")
+ env.InitTrace()
+
+ t.Log("Phase 1: Session with two prompts")
+
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ // First prompt
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function A")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session.TranscriptBuilder.AddToolResult(toolID1)
+
+ // Second prompt in same session
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (second prompt) failed: %v", err)
+ }
+ fileBContent := "package main\n\nfunc B() {}\n"
+ env.WriteFile("b.go", fileBContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function B")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
+ session.TranscriptBuilder.AddToolResult(toolID2)
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ t.Log("Phase 2: First commit")
+
+ env.GitCommitWithShadowHooks("Add functions A and B", "a.go", "b.go")
+ commit1Hash := env.GetHeadHash()
+ checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
+ t.Logf("First commit: %s, checkpoint: %s", commit1Hash[:7], checkpoint1ID)
+
+ // Verify first checkpoint has prompts A and B (session files in numbered subdirectory)
+ prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint1ID, "prompt.txt"))
+ if !found {
+ t.Fatal("First checkpoint should have prompt.txt")
+ }
+ if !strings.Contains(prompt1Content, "function A") || !strings.Contains(prompt1Content, "function B") {
+ t.Errorf("First checkpoint should contain prompts A and B, got: %s", prompt1Content)
+ }
+ t.Logf("First checkpoint prompts:\n%s", prompt1Content)
+
+ t.Log("Phase 3: Continue session with third prompt")
+
+ // Submit the new prompt through the hook so it gets recorded in prompt.txt
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function C"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (third prompt) failed: %v", err)
+ }
+
+ fileCContent := "package main\n\nfunc C() {}\n"
+ env.WriteFile("c.go", fileCContent)
+
+ // Add to transcript
+ session.TranscriptBuilder.AddUserMessage("Create function C")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID3 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "c.go", fileCContent)
+ session.TranscriptBuilder.AddToolResult(toolID3)
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write updated transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (second) failed: %v", err)
+ }
+
+ t.Log("Phase 4: Second commit")
+
+ env.GitCommitWithShadowHooks("Add function C", "c.go")
+ commit2Hash := env.GetHeadHash()
+ checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
+ t.Logf("Second commit: %s, checkpoint: %s", commit2Hash[:7], checkpoint2ID)
+
+ if checkpoint1ID == checkpoint2ID {
+ t.Errorf("Commits should have different checkpoint IDs")
+ }
+
+ t.Log("Phase 5: Verify second checkpoint has only checkpoint-scoped prompt (C)")
+
+ // Session files are now in numbered subdirectory (e.g., 0/prompt.txt)
+ prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint2ID, "prompt.txt"))
+ if !found {
+ t.Fatal("Second checkpoint should have prompt.txt")
+ }
+
+ t.Logf("Second checkpoint prompts:\n%s", prompt2Content)
+
+ // Should contain only the checkpoint-scoped prompt (C), not earlier prompts
+ if !strings.Contains(prompt2Content, "function C") {
+ t.Error("Second checkpoint should contain 'function C'")
+ }
+ if strings.Contains(prompt2Content, "function A") {
+ t.Error("Second checkpoint should NOT contain 'function A' (checkpoint-scoped)")
+ }
+ if strings.Contains(prompt2Content, "function B") {
+ t.Error("Second checkpoint should NOT contain 'function B' (checkpoint-scoped)")
+ }
+
+ t.Log("Checkpoint-scoped prompt condensation with intermediate commits test completed successfully!")
+}
diff --git a/cli/integration_test/manual_commit_workflow_3_test.go b/cli/integration_test/manual_commit_workflow_3_test.go
new file mode 100644
index 0000000..f01e33c
--- /dev/null
+++ b/cli/integration_test/manual_commit_workflow_3_test.go
@@ -0,0 +1,337 @@
+//go:build integration
+
+package integration
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+)
+
+// TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch tests that untracked files
+// present at session start are preserved during rewind, even when the shadow branch already
+// exists from a previous session.
+func TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup repository with initial commit
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/existing-shadow-test")
+ env.InitTrace()
+
+ t.Log("Phase 1: Create untracked file before session starts")
+
+ // Create an untracked file BEFORE the first checkpoint
+ // This simulates configuration files that exist before Claude starts
+ untrackedContent := `{"new": "config"}`
+ env.WriteFile(".claude/settings.json", untrackedContent)
+
+ t.Log("Phase 1: Create a previous session to establish shadow branch")
+
+ // First session - creates the shadow branch
+ session1 := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create old.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ env.WriteFile("old.go", "package main\n")
+ session1.TranscriptBuilder.AddUserMessage("Create old.go")
+ session1.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "old.go", "package main\n")
+ session1.TranscriptBuilder.AddToolResult(toolID)
+
+ if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (session 1) failed: %v", err)
+ }
+
+ // Verify shadow branch exists
+ shadowBranchName := env.GetShadowBranchName()
+ if !env.BranchExists(shadowBranchName) {
+ t.Fatalf("Shadow branch %s should exist after first session", shadowBranchName)
+ }
+ t.Logf("Shadow branch %s exists from first session", shadowBranchName)
+
+ t.Log("Phase 2: Continue session and create second checkpoint")
+
+ // Continue the SAME session (Claude resumes with the same session ID)
+ // This is the expected behavior - continuing work on the same base commit
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create A"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (continue session) failed: %v", err)
+ }
+
+ // Reset transcript builder for next checkpoint
+ session1.TranscriptBuilder = NewTranscriptBuilder()
+
+ // Second checkpoint of session - should capture .claude/settings.json
+ env.WriteFile("a.go", "package main\n\nfunc A() {}\n")
+ session1.TranscriptBuilder.AddUserMessage("Create A")
+ session1.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID2 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", "package main\n\nfunc A() {}\n")
+ session1.TranscriptBuilder.AddToolResult(toolID2)
+
+ if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
+ }
+
+ rewindPoints := env.GetRewindPoints()
+ if len(rewindPoints) < 2 {
+ t.Fatalf("Expected at least 2 rewind points, got %d", len(rewindPoints))
+ }
+ // Find the most recent checkpoint (checkpoint 2)
+ checkpoint1 := &rewindPoints[0] // Most recent first
+ t.Logf("Checkpoint 2: %s", checkpoint1.ID[:7])
+
+ t.Log("Phase 3: Create third checkpoint")
+
+ // Continue the session for the third checkpoint
+ if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create B"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt (checkpoint 3) failed: %v", err)
+ }
+
+ // Reset transcript builder for next checkpoint
+ session1.TranscriptBuilder = NewTranscriptBuilder()
+
+ env.WriteFile("b.go", "package main\n\nfunc B() {}\n")
+ session1.TranscriptBuilder.AddUserMessage("Create B")
+ session1.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID3 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", "package main\n\nfunc B() {}\n")
+ session1.TranscriptBuilder.AddToolResult(toolID3)
+
+ if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop (checkpoint 3) failed: %v", err)
+ }
+
+ t.Log("Phase 4: Rewind to checkpoint 2")
+
+ if err := env.Rewind(checkpoint1.ID); err != nil {
+ t.Fatalf("Rewind failed: %v", err)
+ }
+
+ // Verify that the untracked file that existed at session start is PRESERVED
+ // Since .claude/settings.json was created before checkpoint 1, it's in checkpoint 1's tree
+ // and will flow through to checkpoint 2, so it should be preserved on rewind
+ if !env.FileExists(".claude/settings.json") {
+ t.Error(".claude/settings.json should have been preserved during rewind")
+ } else {
+ restoredContent := env.ReadFile(".claude/settings.json")
+ if restoredContent != untrackedContent {
+ t.Errorf("Untracked file content changed.\nExpected:\n%s\nGot:\n%s", untrackedContent, restoredContent)
+ } else {
+ t.Log("✓ .claude/settings.json was preserved correctly")
+ }
+ }
+
+ // Verify b.go was deleted
+ if env.FileExists("b.go") {
+ t.Error("b.go should have been deleted during rewind")
+ } else {
+ t.Log("✓ b.go was correctly deleted during rewind")
+ }
+
+ t.Log("Test completed successfully!")
+}
+
+// TestShadow_TrailerRemovalSkipsCondensation tests that removing the Trace-Checkpoint
+// trailer during commit message editing causes condensation to be skipped.
+// This allows users to opt-out of linking a commit to their Claude session.
+func TestShadow_TrailerRemovalSkipsCondensation(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/trailer-opt-out")
+ env.InitTrace()
+
+ t.Log("Phase 1: Create session with content")
+
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ fileAContent := "package main\n\nfunc A() {}\n"
+ env.WriteFile("a.go", fileAContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function A")
+ session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
+ toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
+ session.TranscriptBuilder.AddToolResult(toolID)
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ t.Log("Phase 2: Commit WITH trailer removed (user opts out)")
+
+ // Use the special helper that removes the trailer before committing
+ env.GitCommitWithTrailerRemoved("Add function A (manual commit)", "a.go")
+
+ commitHash := env.GetHeadHash()
+ t.Logf("Commit: %s", commitHash[:7])
+
+ // Verify commit does NOT have trailer
+ commitMsg := env.GetCommitMessage(commitHash)
+ if _, found := trailers.ParseCheckpoint(commitMsg); found {
+ t.Errorf("Commit should NOT have Trace-Checkpoint trailer (it was removed), got:\n%s", commitMsg)
+ }
+ t.Logf("Commit message (trailer removed):\n%s", commitMsg)
+
+ t.Log("Phase 3: Verify no condensation happened")
+
+ // trace/checkpoints/v1 branch exists (created at setup), but should not have any checkpoint commits yet
+ // since the user removed the trailer
+ latestCheckpointID := env.TryGetLatestCheckpointID()
+ if latestCheckpointID == "" {
+ t.Log("✓ No checkpoint found on trace/checkpoints/v1 branch (no condensation)")
+ } else {
+ // If there is a checkpoint, this is unexpected for this test
+ t.Logf("Found checkpoint ID: %s (should be from previous activity, not this commit)", latestCheckpointID)
+ }
+
+ t.Log("Phase 4: Now commit WITH trailer (user keeps it)")
+
+ // Continue session with new content
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ fileBContent := "package main\n\nfunc B() {}\n"
+ env.WriteFile("b.go", fileBContent)
+
+ session.TranscriptBuilder.AddUserMessage("Create function B")
+ session.TranscriptBuilder.AddAssistantMessage("Done!")
+ toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
+ session.TranscriptBuilder.AddToolResult(toolID2)
+
+ if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
+ t.Fatalf("Failed to write transcript: %v", err)
+ }
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // This time, keep the trailer (normal commit with hooks)
+ env.GitCommitWithShadowHooks("Add function B", "b.go")
+
+ commit2Hash := env.GetHeadHash()
+ checkpointID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
+ t.Logf("Second commit: %s, checkpoint: %s", commit2Hash[:7], checkpointID)
+
+ // Verify second commit HAS trailer with valid format
+ commit2Msg := env.GetCommitMessage(commit2Hash)
+ if _, found := trailers.ParseCheckpoint(commit2Msg); !found {
+ t.Errorf("Second commit should have valid Trace-Checkpoint trailer, got:\n%s", commit2Msg)
+ }
+
+ // Verify condensation happened for second commit
+ if !env.BranchExists(paths.MetadataBranchName) {
+ t.Fatal("trace/checkpoints/v1 branch should exist after second commit with trailer")
+ }
+
+ // Verify checkpoint exists
+ shardedPath := ShardedCheckpointPath(checkpointID)
+ metadataPath := shardedPath + "/metadata.json"
+ if !env.FileExistsInBranch(paths.MetadataBranchName, metadataPath) {
+ t.Errorf("Checkpoint should exist at %s", metadataPath)
+ } else {
+ t.Log("✓ Condensation happened for commit with trailer")
+ }
+
+ t.Log("Trailer removal opt-out test completed successfully!")
+}
+
+// TestShadow_SessionsBranchCommitTrailers verifies that commits on the trace/checkpoints/v1
+// branch contain the expected trailers: Trace-Session, Trace-Strategy, and Trace-Agent.
+func TestShadow_SessionsBranchCommitTrailers(t *testing.T) {
+ t.Parallel()
+ env := NewTestEnv(t)
+ defer env.Cleanup()
+
+ // Setup
+ env.InitRepo()
+ env.WriteFile("README.md", "# Test Repository")
+ env.GitAdd("README.md")
+ env.GitCommit("Initial commit")
+ env.GitCheckoutNewBranch("feature/trailer-test")
+ env.InitTrace()
+
+ // Start session and create checkpoint
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create main.go"); err != nil {
+ t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
+ }
+
+ fileContent := "package main\n\nfunc main() {}\n"
+ env.WriteFile("main.go", fileContent)
+ session.CreateTranscript("Create main.go", []FileChange{{Path: "main.go", Content: fileContent}})
+
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // Commit to trigger condensation
+ env.GitCommitWithShadowHooks("Add main.go", "main.go")
+
+ // Get the commit message on trace/checkpoints/v1 branch
+ sessionsCommitMsg := env.GetLatestCommitMessageOnBranch(paths.MetadataBranchName)
+ t.Logf("trace/checkpoints/v1 commit message:\n%s", sessionsCommitMsg)
+
+ // Verify required trailers are present
+ requiredTrailers := map[string]string{
+ trailers.SessionTrailerKey: "", // Trace-Session:
+ trailers.StrategyTrailerKey: strategy.StrategyNameManualCommit, // Trace-Strategy: manual-commit
+ trailers.AgentTrailerKey: "Claude Code", // Trace-Agent: Claude Code
+ }
+
+ for trailerKey, expectedValue := range requiredTrailers {
+ if !strings.Contains(sessionsCommitMsg, trailerKey+":") {
+ t.Errorf("trace/checkpoints/v1 commit should have %s trailer", trailerKey)
+ continue
+ }
+
+ // If we have an expected value, verify it
+ if expectedValue != "" {
+ expectedTrailer := trailerKey + ": " + expectedValue
+ if !strings.Contains(sessionsCommitMsg, expectedTrailer) {
+ t.Errorf("trace/checkpoints/v1 commit should have %q, got message:\n%s", expectedTrailer, sessionsCommitMsg)
+ } else {
+ t.Logf("✓ Found trailer: %s", expectedTrailer)
+ }
+ } else {
+ t.Logf("✓ Found trailer: %s", trailerKey)
+ }
+ }
+
+ t.Log("Sessions branch commit trailers test completed successfully!")
+}
diff --git a/cli/integration_test/manual_commit_workflow_test.go b/cli/integration_test/manual_commit_workflow_test.go
index d072136..780431a 100644
--- a/cli/integration_test/manual_commit_workflow_test.go
+++ b/cli/integration_test/manual_commit_workflow_test.go
@@ -13,7 +13,6 @@ import (
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/strategy"
- "github.com/GrayCodeAI/trace/cli/trailers"
)
// TestShadow_FullWorkflow tests the complete shadow workflow as described in
@@ -703,1000 +702,3 @@ func TestShadow_TranscriptCondensation(t *testing.T) {
t.Logf("✓ Session metadata has agent: %q", sessionMetadata.Agent)
}
}
-
-// TestShadow_FullTranscriptContext verifies that each checkpoint includes
-// only the prompts from its checkpoint portion, not the trace session.
-//
-// This tests checkpoint-scoped prompts:
-// - First commit: prompt.txt includes prompts 1-2 (from checkpoint start)
-// - Second commit: prompt.txt includes only prompt 3 (from second checkpoint start)
-func TestShadow_FullTranscriptContext(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/incremental")
- env.InitTrace()
-
- t.Log("Phase 1: First session with two prompts")
-
- // Start first session
- session1 := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create function A in a.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // First prompt: create file A
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- // Build transcript with first prompt
- session1.TranscriptBuilder.AddUserMessage("Create function A in a.go")
- session1.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
- toolID1 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session1.TranscriptBuilder.AddToolResult(toolID1)
- session1.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
-
- // Second prompt in same session: create file B
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Now create function B in b.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (second prompt) failed: %v", err)
- }
- fileBContent := "package main\n\nfunc B() {}\n"
- env.WriteFile("b.go", fileBContent)
-
- session1.TranscriptBuilder.AddUserMessage("Now create function B in b.go")
- session1.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.")
- toolID2 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
- session1.TranscriptBuilder.AddToolResult(toolID2)
- session1.TranscriptBuilder.AddAssistantMessage("Done creating function B!")
-
- // Write transcript
- if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- // Save checkpoint (triggers SaveStep)
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- t.Log("Phase 2: First user commit")
-
- // User commits
- env.GitCommitWithShadowHooks("Add functions A and B", "a.go", "b.go")
-
- // Get first checkpoint ID from commit message trailer
- commit1Hash := env.GetHeadHash()
- checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
- t.Logf("First checkpoint ID: %s", checkpoint1ID)
-
- // Verify first checkpoint has both prompts (uses session file path in numbered subdirectory)
- promptPath1 := SessionFilePath(checkpoint1ID, "prompt.txt")
- prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath1)
- if !found {
- t.Errorf("prompt.txt should exist at %s", promptPath1)
- } else {
- t.Logf("First prompt.txt content:\n%s", prompt1Content)
- // Should contain both "Create function A" and "create function B"
- if !strings.Contains(prompt1Content, "Create function A") {
- t.Error("First prompt.txt should contain 'Create function A'")
- }
- if !strings.Contains(prompt1Content, "create function B") {
- t.Error("First prompt.txt should contain 'create function B'")
- }
- }
-
- t.Log("Phase 3: Continue session with third prompt")
-
- // Continue the session with a new prompt
- // First, simulate another user prompt submit to track the new base
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Finally, create function C in c.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (continued) failed: %v", err)
- }
-
- // Third prompt: create file C
- fileCContent := "package main\n\nfunc C() {}\n"
- env.WriteFile("c.go", fileCContent)
-
- // Add to transcript (continuing from previous)
- session1.TranscriptBuilder.AddUserMessage("Finally, create function C in c.go")
- session1.TranscriptBuilder.AddAssistantMessage("I'll create function C for you.")
- toolID3 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "c.go", fileCContent)
- session1.TranscriptBuilder.AddToolResult(toolID3)
- session1.TranscriptBuilder.AddAssistantMessage("Done creating function C!")
-
- // Write updated transcript
- if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
- t.Fatalf("Failed to write updated transcript: %v", err)
- }
-
- // Save checkpoint
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (second) failed: %v", err)
- }
-
- t.Log("Phase 4: Second user commit")
-
- // User commits again
- env.GitCommitWithShadowHooks("Add function C", "c.go")
-
- // Get second checkpoint ID from commit message trailer
- commit2Hash := env.GetHeadHash()
- checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
- t.Logf("Second checkpoint ID: %s", checkpoint2ID)
-
- // Verify different checkpoint IDs
- if checkpoint1ID == checkpoint2ID {
- t.Errorf("Second commit should have different checkpoint ID: %s vs %s", checkpoint1ID, checkpoint2ID)
- }
-
- t.Log("Phase 5: Verify full transcript preserved in second checkpoint")
-
- // Verify second checkpoint has the FULL transcript (all three prompts)
- // Session files are now in numbered subdirectories (e.g., 0/prompt.txt)
- promptPath2 := SessionFilePath(checkpoint2ID, "prompt.txt")
- prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath2)
- if !found {
- t.Errorf("prompt.txt should exist at %s", promptPath2)
- } else {
- t.Logf("Second prompt.txt content:\n%s", prompt2Content)
-
- // Should contain only the checkpoint-scoped prompt (third prompt only)
- if !strings.Contains(prompt2Content, "create function C") {
- t.Error("Second prompt.txt should contain 'create function C'")
- }
- }
-
- t.Log("Shadow full transcript context test completed successfully!")
-}
-
-// TestShadow_RewindAndCondensation verifies that after rewinding to an earlier
-// checkpoint, the checkpoint only includes prompts up to that point.
-//
-// Workflow:
-// 1. Create checkpoint 1 (prompt 1)
-// 2. Create checkpoint 2 (prompt 2)
-// 3. Rewind to checkpoint 1
-// 4. User commits
-// 5. Verify checkpoint only contains prompt 1 (NOT prompt 2)
-func TestShadow_RewindAndCondensation(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/rewind-test")
- env.InitTrace()
-
- t.Log("Phase 1: Create first checkpoint with prompt 1")
-
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A in a.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // First prompt: create file A
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function A in a.go")
- session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
- toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session.TranscriptBuilder.AddToolResult(toolID1)
- session.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 1) failed: %v", err)
- }
-
- // Get checkpoint 1 for later
- rewindPoints := env.GetRewindPoints()
- if len(rewindPoints) != 1 {
- t.Fatalf("Expected 1 rewind point after checkpoint 1, got %d", len(rewindPoints))
- }
- checkpoint1 := rewindPoints[0]
- t.Logf("Checkpoint 1: %s - %s", checkpoint1.ID[:7], checkpoint1.Message)
-
- t.Log("Phase 2: Create second checkpoint with prompt 2")
-
- // Second prompt: modify file A (a different approach)
- fileAModified := "package main\n\nfunc A() {\n\t// Modified version\n}\n"
- env.WriteFile("a.go", fileAModified)
-
- session.TranscriptBuilder.AddUserMessage("Actually, modify function A to have a comment")
- session.TranscriptBuilder.AddAssistantMessage("I'll modify function A for you.")
- toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAModified)
- session.TranscriptBuilder.AddToolResult(toolID2)
- session.TranscriptBuilder.AddAssistantMessage("Done modifying function A!")
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
- }
-
- rewindPoints = env.GetRewindPoints()
- if len(rewindPoints) != 2 {
- t.Fatalf("Expected 2 rewind points after checkpoint 2, got %d", len(rewindPoints))
- }
- t.Logf("Checkpoint 2: %s - %s", rewindPoints[0].ID[:7], rewindPoints[0].Message)
-
- // Verify file has modified content
- currentContent := env.ReadFile("a.go")
- if currentContent != fileAModified {
- t.Errorf("a.go should have modified content before rewind")
- }
-
- t.Log("Phase 3: Rewind to checkpoint 1")
-
- // Rewind using the CLI (which calls the strategy internally)
- if err := env.Rewind(checkpoint1.ID); err != nil {
- t.Fatalf("Rewind failed: %v", err)
- }
-
- // Verify file content is restored to checkpoint 1
- restoredContent := env.ReadFile("a.go")
- if restoredContent != fileAContent {
- t.Errorf("a.go should have original content after rewind.\nExpected:\n%s\nGot:\n%s", fileAContent, restoredContent)
- }
- t.Log("Files successfully restored to checkpoint 1")
-
- t.Log("Phase 4: User commits after rewind")
-
- // User commits - this should trigger condensation
- env.GitCommitWithShadowHooks("Add function A (reverted)", "a.go")
-
- // Get checkpoint ID from commit message trailer
- commitHash := env.GetHeadHash()
- checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash)
- t.Logf("Checkpoint ID: %s", checkpointID)
-
- t.Log("Phase 5: Verify checkpoint only contains prompt 1")
-
- // Check prompt.txt (uses session file path in numbered subdirectory)
- promptPath := SessionFilePath(checkpointID, "prompt.txt")
- promptContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath)
- if !found {
- t.Errorf("prompt.txt should exist at %s", promptPath)
- } else {
- t.Logf("prompt.txt content:\n%s", promptContent)
-
- // Should contain prompt 1
- if !strings.Contains(promptContent, "Create function A") {
- t.Error("prompt.txt should contain 'Create function A' from checkpoint 1")
- }
-
- // Should NOT contain prompt 2 (because we rewound past it)
- if strings.Contains(promptContent, "modify function A") {
- t.Error("prompt.txt should NOT contain 'modify function A' - we rewound past that checkpoint")
- }
- }
-
- t.Log("Shadow rewind and condensation test completed successfully!")
-}
-
-// TestShadow_RewindPreservesUntrackedFilesFromSessionStart tests that files that existed
-// in the working directory (but weren't tracked in git) before the session started are
-// preserved when rewinding. This was a bug where such files were incorrectly deleted.
-func TestShadow_RewindPreservesUntrackedFilesFromSessionStart(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository with initial commit
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/untracked-test")
-
- // Create an untracked file BEFORE initializing Trace session
- // This simulates files like .claude/settings.json created by "trace setup"
- untrackedContent := `{"key": "value"}`
- env.WriteFile(".claude/settings.json", untrackedContent)
-
- // Initialize Trace with manual-commit strategy
- env.InitTrace()
-
- t.Log("Phase 1: Create first checkpoint")
-
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // First prompt: create file A
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function A")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session.TranscriptBuilder.AddToolResult(toolID1)
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 1) failed: %v", err)
- }
-
- rewindPoints := env.GetRewindPoints()
- if len(rewindPoints) != 1 {
- t.Fatalf("Expected 1 rewind point, got %d", len(rewindPoints))
- }
- checkpoint1 := rewindPoints[0]
- t.Logf("Checkpoint 1: %s", checkpoint1.ID[:7])
-
- t.Log("Phase 2: Create second checkpoint")
-
- // Second prompt: create file B
- fileBContent := "package main\n\nfunc B() {}\n"
- env.WriteFile("b.go", fileBContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function B")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
- session.TranscriptBuilder.AddToolResult(toolID2)
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
- }
-
- rewindPoints = env.GetRewindPoints()
- if len(rewindPoints) != 2 {
- t.Fatalf("Expected 2 rewind points, got %d", len(rewindPoints))
- }
- t.Logf("Checkpoint 2: %s", rewindPoints[0].ID[:7])
-
- // Verify the untracked file still exists before rewind
- if !env.FileExists(".claude/settings.json") {
- t.Fatal("Untracked file .claude/settings.json should exist before rewind")
- }
-
- t.Log("Phase 3: Rewind to checkpoint 1")
-
- if err := env.Rewind(checkpoint1.ID); err != nil {
- t.Fatalf("Rewind failed: %v", err)
- }
-
- // Verify that the untracked file that existed before session start is PRESERVED
- if !env.FileExists(".claude/settings.json") {
- t.Error("CRITICAL: .claude/settings.json was deleted during rewind but it existed before the session started!")
- } else {
- restoredContent := env.ReadFile(".claude/settings.json")
- if restoredContent != untrackedContent {
- t.Errorf("Untracked file content changed.\nExpected:\n%s\nGot:\n%s", untrackedContent, restoredContent)
- } else {
- t.Log("✓ Untracked file .claude/settings.json was preserved correctly")
- }
- }
-
- // Verify b.go was deleted (it was created after checkpoint 1)
- if env.FileExists("b.go") {
- t.Error("b.go should have been deleted during rewind (it was created after checkpoint 1)")
- } else {
- t.Log("✓ b.go was correctly deleted during rewind")
- }
-
- // Verify a.go was restored
- if !env.FileExists("a.go") {
- t.Error("a.go should exist after rewind to checkpoint 1")
- } else {
- restoredA := env.ReadFile("a.go")
- if restoredA != fileAContent {
- t.Errorf("a.go content incorrect after rewind")
- } else {
- t.Log("✓ a.go was correctly restored")
- }
- }
-
- t.Log("Test completed successfully!")
-}
-
-// TestShadow_IntermediateCommitsWithoutPrompts tests that commits without new Claude
-// content do NOT get checkpoint trailers.
-//
-// Scenario:
-// 1. Session starts, work happens, checkpoint created
-// 2. First commit gets a trailer (has new content)
-// 3. User commits unrelated files without new Claude work - NO trailer (no new content)
-// 4. User enters new prompt, creates more files
-// 5. Second commit with Claude content gets a trailer
-func TestShadow_IntermediateCommitsWithoutPrompts(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/intermediate-commits")
- env.InitTrace()
-
- t.Log("Phase 1: Start session and create checkpoint")
-
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A in a.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // First prompt: create file A
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function A in a.go")
- session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
- toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session.TranscriptBuilder.AddToolResult(toolID1)
- session.TranscriptBuilder.AddAssistantMessage("Done creating function A!")
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- t.Log("Phase 2: First commit (with session content)")
-
- env.GitCommitWithShadowHooks("Add function A", "a.go")
- commit1Hash := env.GetHeadHash()
- checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
- t.Logf("First commit: %s, checkpoint from trailer: %s", commit1Hash[:7], checkpoint1ID)
- t.Logf("First commit message:\n%s", env.GetCommitMessage(commit1Hash))
-
- if checkpoint1ID == "" {
- t.Fatal("First commit should have a checkpoint ID in its trailer (has new content)")
- }
-
- t.Log("Phase 3: Create unrelated file and commit WITHOUT new prompt")
-
- // User creates an unrelated file and commits without entering a new Claude prompt
- // Since there's no new session content, this commit should NOT get a trailer
- env.WriteFile("unrelated.txt", "This is an unrelated file")
- env.GitCommitWithShadowHooks("Add unrelated file", "unrelated.txt")
-
- commit2Hash := env.GetHeadHash()
- checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
- t.Logf("Second commit: %s, checkpoint from trailer: %s", commit2Hash[:7], checkpoint2ID)
- t.Logf("Second commit message:\n%s", env.GetCommitMessage(commit2Hash))
-
- // Second commit should NOT get a checkpoint ID (no new session content)
- if checkpoint2ID != "" {
- t.Errorf("Second commit should NOT have a checkpoint trailer (no new content), got: %s", checkpoint2ID)
- }
-
- t.Log("Phase 4: New Claude work and commit")
-
- // Now user enters new prompt and does more work
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B in b.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- fileBContent := "package main\n\nfunc B() {}\n"
- env.WriteFile("b.go", fileBContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function B in b.go")
- session.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.")
- toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
- session.TranscriptBuilder.AddToolResult(toolID2)
- session.TranscriptBuilder.AddAssistantMessage("Done creating function B!")
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- env.GitCommitWithShadowHooks("Add function B", "b.go")
-
- commit3Hash := env.GetHeadHash()
- checkpoint3ID := env.GetCheckpointIDFromCommitMessage(commit3Hash)
- t.Logf("Third commit: %s, checkpoint from trailer: %s", commit3Hash[:7], checkpoint3ID)
- t.Logf("Third commit message:\n%s", env.GetCommitMessage(commit3Hash))
-
- if checkpoint3ID == "" {
- t.Fatal("Third commit should have a checkpoint ID (has new content)")
- }
-
- // First and third checkpoint IDs should be different
- if checkpoint1ID == checkpoint3ID {
- t.Errorf("First and third commits should have different checkpoint IDs: %s vs %s",
- checkpoint1ID, checkpoint3ID)
- }
-
- t.Log("Phase 5: Verify checkpoints exist in trace/checkpoints/v1")
-
- for _, cpID := range []string{checkpoint1ID, checkpoint3ID} {
- shardedPath := ShardedCheckpointPath(cpID)
- metadataPath := shardedPath + "/metadata.json"
- if !env.FileExistsInBranch(paths.MetadataBranchName, metadataPath) {
- t.Errorf("Checkpoint %s should have metadata.json at %s", cpID, metadataPath)
- }
- }
-
- t.Log("Intermediate commits test completed successfully!")
-}
-
-// TestShadow_FullTranscriptCondensationWithIntermediateCommits tests that checkpoints
-// contain only checkpoint-scoped prompts across multiple commits.
-//
-// Scenario:
-// 1. Session with prompts A and B, commit 1 → prompt.txt has A and B
-// 2. Continue session with prompt C, commit 2 (without intermediate prompt submit)
-// 3. Verify commit 2's prompt.txt has only C (checkpoint-scoped)
-func TestShadow_FullTranscriptCondensationWithIntermediateCommits(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/incremental-intermediate")
- env.InitTrace()
-
- t.Log("Phase 1: Session with two prompts")
-
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- // First prompt
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function A")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session.TranscriptBuilder.AddToolResult(toolID1)
-
- // Second prompt in same session
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (second prompt) failed: %v", err)
- }
- fileBContent := "package main\n\nfunc B() {}\n"
- env.WriteFile("b.go", fileBContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function B")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
- session.TranscriptBuilder.AddToolResult(toolID2)
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- t.Log("Phase 2: First commit")
-
- env.GitCommitWithShadowHooks("Add functions A and B", "a.go", "b.go")
- commit1Hash := env.GetHeadHash()
- checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash)
- t.Logf("First commit: %s, checkpoint: %s", commit1Hash[:7], checkpoint1ID)
-
- // Verify first checkpoint has prompts A and B (session files in numbered subdirectory)
- prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint1ID, "prompt.txt"))
- if !found {
- t.Fatal("First checkpoint should have prompt.txt")
- }
- if !strings.Contains(prompt1Content, "function A") || !strings.Contains(prompt1Content, "function B") {
- t.Errorf("First checkpoint should contain prompts A and B, got: %s", prompt1Content)
- }
- t.Logf("First checkpoint prompts:\n%s", prompt1Content)
-
- t.Log("Phase 3: Continue session with third prompt")
-
- // Submit the new prompt through the hook so it gets recorded in prompt.txt
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function C"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (third prompt) failed: %v", err)
- }
-
- fileCContent := "package main\n\nfunc C() {}\n"
- env.WriteFile("c.go", fileCContent)
-
- // Add to transcript
- session.TranscriptBuilder.AddUserMessage("Create function C")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID3 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "c.go", fileCContent)
- session.TranscriptBuilder.AddToolResult(toolID3)
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write updated transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (second) failed: %v", err)
- }
-
- t.Log("Phase 4: Second commit")
-
- env.GitCommitWithShadowHooks("Add function C", "c.go")
- commit2Hash := env.GetHeadHash()
- checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
- t.Logf("Second commit: %s, checkpoint: %s", commit2Hash[:7], checkpoint2ID)
-
- if checkpoint1ID == checkpoint2ID {
- t.Errorf("Commits should have different checkpoint IDs")
- }
-
- t.Log("Phase 5: Verify second checkpoint has only checkpoint-scoped prompt (C)")
-
- // Session files are now in numbered subdirectory (e.g., 0/prompt.txt)
- prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint2ID, "prompt.txt"))
- if !found {
- t.Fatal("Second checkpoint should have prompt.txt")
- }
-
- t.Logf("Second checkpoint prompts:\n%s", prompt2Content)
-
- // Should contain only the checkpoint-scoped prompt (C), not earlier prompts
- if !strings.Contains(prompt2Content, "function C") {
- t.Error("Second checkpoint should contain 'function C'")
- }
- if strings.Contains(prompt2Content, "function A") {
- t.Error("Second checkpoint should NOT contain 'function A' (checkpoint-scoped)")
- }
- if strings.Contains(prompt2Content, "function B") {
- t.Error("Second checkpoint should NOT contain 'function B' (checkpoint-scoped)")
- }
-
- t.Log("Checkpoint-scoped prompt condensation with intermediate commits test completed successfully!")
-}
-
-// TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch tests that untracked files
-// present at session start are preserved during rewind, even when the shadow branch already
-// exists from a previous session.
-func TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup repository with initial commit
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/existing-shadow-test")
- env.InitTrace()
-
- t.Log("Phase 1: Create untracked file before session starts")
-
- // Create an untracked file BEFORE the first checkpoint
- // This simulates configuration files that exist before Claude starts
- untrackedContent := `{"new": "config"}`
- env.WriteFile(".claude/settings.json", untrackedContent)
-
- t.Log("Phase 1: Create a previous session to establish shadow branch")
-
- // First session - creates the shadow branch
- session1 := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create old.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- env.WriteFile("old.go", "package main\n")
- session1.TranscriptBuilder.AddUserMessage("Create old.go")
- session1.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "old.go", "package main\n")
- session1.TranscriptBuilder.AddToolResult(toolID)
-
- if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (session 1) failed: %v", err)
- }
-
- // Verify shadow branch exists
- shadowBranchName := env.GetShadowBranchName()
- if !env.BranchExists(shadowBranchName) {
- t.Fatalf("Shadow branch %s should exist after first session", shadowBranchName)
- }
- t.Logf("Shadow branch %s exists from first session", shadowBranchName)
-
- t.Log("Phase 2: Continue session and create second checkpoint")
-
- // Continue the SAME session (Claude resumes with the same session ID)
- // This is the expected behavior - continuing work on the same base commit
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create A"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (continue session) failed: %v", err)
- }
-
- // Reset transcript builder for next checkpoint
- session1.TranscriptBuilder = NewTranscriptBuilder()
-
- // Second checkpoint of session - should capture .claude/settings.json
- env.WriteFile("a.go", "package main\n\nfunc A() {}\n")
- session1.TranscriptBuilder.AddUserMessage("Create A")
- session1.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID2 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", "package main\n\nfunc A() {}\n")
- session1.TranscriptBuilder.AddToolResult(toolID2)
-
- if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 2) failed: %v", err)
- }
-
- rewindPoints := env.GetRewindPoints()
- if len(rewindPoints) < 2 {
- t.Fatalf("Expected at least 2 rewind points, got %d", len(rewindPoints))
- }
- // Find the most recent checkpoint (checkpoint 2)
- checkpoint1 := &rewindPoints[0] // Most recent first
- t.Logf("Checkpoint 2: %s", checkpoint1.ID[:7])
-
- t.Log("Phase 3: Create third checkpoint")
-
- // Continue the session for the third checkpoint
- if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Create B"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt (checkpoint 3) failed: %v", err)
- }
-
- // Reset transcript builder for next checkpoint
- session1.TranscriptBuilder = NewTranscriptBuilder()
-
- env.WriteFile("b.go", "package main\n\nfunc B() {}\n")
- session1.TranscriptBuilder.AddUserMessage("Create B")
- session1.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID3 := session1.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", "package main\n\nfunc B() {}\n")
- session1.TranscriptBuilder.AddToolResult(toolID3)
-
- if err := session1.TranscriptBuilder.WriteToFile(session1.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop (checkpoint 3) failed: %v", err)
- }
-
- t.Log("Phase 4: Rewind to checkpoint 2")
-
- if err := env.Rewind(checkpoint1.ID); err != nil {
- t.Fatalf("Rewind failed: %v", err)
- }
-
- // Verify that the untracked file that existed at session start is PRESERVED
- // Since .claude/settings.json was created before checkpoint 1, it's in checkpoint 1's tree
- // and will flow through to checkpoint 2, so it should be preserved on rewind
- if !env.FileExists(".claude/settings.json") {
- t.Error(".claude/settings.json should have been preserved during rewind")
- } else {
- restoredContent := env.ReadFile(".claude/settings.json")
- if restoredContent != untrackedContent {
- t.Errorf("Untracked file content changed.\nExpected:\n%s\nGot:\n%s", untrackedContent, restoredContent)
- } else {
- t.Log("✓ .claude/settings.json was preserved correctly")
- }
- }
-
- // Verify b.go was deleted
- if env.FileExists("b.go") {
- t.Error("b.go should have been deleted during rewind")
- } else {
- t.Log("✓ b.go was correctly deleted during rewind")
- }
-
- t.Log("Test completed successfully!")
-}
-
-// TestShadow_TrailerRemovalSkipsCondensation tests that removing the Trace-Checkpoint
-// trailer during commit message editing causes condensation to be skipped.
-// This allows users to opt-out of linking a commit to their Claude session.
-func TestShadow_TrailerRemovalSkipsCondensation(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/trailer-opt-out")
- env.InitTrace()
-
- t.Log("Phase 1: Create session with content")
-
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function A"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- fileAContent := "package main\n\nfunc A() {}\n"
- env.WriteFile("a.go", fileAContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function A")
- session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.")
- toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent)
- session.TranscriptBuilder.AddToolResult(toolID)
- session.TranscriptBuilder.AddAssistantMessage("Done!")
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- t.Log("Phase 2: Commit WITH trailer removed (user opts out)")
-
- // Use the special helper that removes the trailer before committing
- env.GitCommitWithTrailerRemoved("Add function A (manual commit)", "a.go")
-
- commitHash := env.GetHeadHash()
- t.Logf("Commit: %s", commitHash[:7])
-
- // Verify commit does NOT have trailer
- commitMsg := env.GetCommitMessage(commitHash)
- if _, found := trailers.ParseCheckpoint(commitMsg); found {
- t.Errorf("Commit should NOT have Trace-Checkpoint trailer (it was removed), got:\n%s", commitMsg)
- }
- t.Logf("Commit message (trailer removed):\n%s", commitMsg)
-
- t.Log("Phase 3: Verify no condensation happened")
-
- // trace/checkpoints/v1 branch exists (created at setup), but should not have any checkpoint commits yet
- // since the user removed the trailer
- latestCheckpointID := env.TryGetLatestCheckpointID()
- if latestCheckpointID == "" {
- t.Log("✓ No checkpoint found on trace/checkpoints/v1 branch (no condensation)")
- } else {
- // If there is a checkpoint, this is unexpected for this test
- t.Logf("Found checkpoint ID: %s (should be from previous activity, not this commit)", latestCheckpointID)
- }
-
- t.Log("Phase 4: Now commit WITH trailer (user keeps it)")
-
- // Continue session with new content
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create function B"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- fileBContent := "package main\n\nfunc B() {}\n"
- env.WriteFile("b.go", fileBContent)
-
- session.TranscriptBuilder.AddUserMessage("Create function B")
- session.TranscriptBuilder.AddAssistantMessage("Done!")
- toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent)
- session.TranscriptBuilder.AddToolResult(toolID2)
-
- if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil {
- t.Fatalf("Failed to write transcript: %v", err)
- }
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // This time, keep the trailer (normal commit with hooks)
- env.GitCommitWithShadowHooks("Add function B", "b.go")
-
- commit2Hash := env.GetHeadHash()
- checkpointID := env.GetCheckpointIDFromCommitMessage(commit2Hash)
- t.Logf("Second commit: %s, checkpoint: %s", commit2Hash[:7], checkpointID)
-
- // Verify second commit HAS trailer with valid format
- commit2Msg := env.GetCommitMessage(commit2Hash)
- if _, found := trailers.ParseCheckpoint(commit2Msg); !found {
- t.Errorf("Second commit should have valid Trace-Checkpoint trailer, got:\n%s", commit2Msg)
- }
-
- // Verify condensation happened for second commit
- if !env.BranchExists(paths.MetadataBranchName) {
- t.Fatal("trace/checkpoints/v1 branch should exist after second commit with trailer")
- }
-
- // Verify checkpoint exists
- shardedPath := ShardedCheckpointPath(checkpointID)
- metadataPath := shardedPath + "/metadata.json"
- if !env.FileExistsInBranch(paths.MetadataBranchName, metadataPath) {
- t.Errorf("Checkpoint should exist at %s", metadataPath)
- } else {
- t.Log("✓ Condensation happened for commit with trailer")
- }
-
- t.Log("Trailer removal opt-out test completed successfully!")
-}
-
-// TestShadow_SessionsBranchCommitTrailers verifies that commits on the trace/checkpoints/v1
-// branch contain the expected trailers: Trace-Session, Trace-Strategy, and Trace-Agent.
-func TestShadow_SessionsBranchCommitTrailers(t *testing.T) {
- t.Parallel()
- env := NewTestEnv(t)
- defer env.Cleanup()
-
- // Setup
- env.InitRepo()
- env.WriteFile("README.md", "# Test Repository")
- env.GitAdd("README.md")
- env.GitCommit("Initial commit")
- env.GitCheckoutNewBranch("feature/trailer-test")
- env.InitTrace()
-
- // Start session and create checkpoint
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create main.go"); err != nil {
- t.Fatalf("SimulateUserPromptSubmitWithPrompt failed: %v", err)
- }
-
- fileContent := "package main\n\nfunc main() {}\n"
- env.WriteFile("main.go", fileContent)
- session.CreateTranscript("Create main.go", []FileChange{{Path: "main.go", Content: fileContent}})
-
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // Commit to trigger condensation
- env.GitCommitWithShadowHooks("Add main.go", "main.go")
-
- // Get the commit message on trace/checkpoints/v1 branch
- sessionsCommitMsg := env.GetLatestCommitMessageOnBranch(paths.MetadataBranchName)
- t.Logf("trace/checkpoints/v1 commit message:\n%s", sessionsCommitMsg)
-
- // Verify required trailers are present
- requiredTrailers := map[string]string{
- trailers.SessionTrailerKey: "", // Trace-Session:
- trailers.StrategyTrailerKey: strategy.StrategyNameManualCommit, // Trace-Strategy: manual-commit
- trailers.AgentTrailerKey: "Claude Code", // Trace-Agent: Claude Code
- }
-
- for trailerKey, expectedValue := range requiredTrailers {
- if !strings.Contains(sessionsCommitMsg, trailerKey+":") {
- t.Errorf("trace/checkpoints/v1 commit should have %s trailer", trailerKey)
- continue
- }
-
- // If we have an expected value, verify it
- if expectedValue != "" {
- expectedTrailer := trailerKey + ": " + expectedValue
- if !strings.Contains(sessionsCommitMsg, expectedTrailer) {
- t.Errorf("trace/checkpoints/v1 commit should have %q, got message:\n%s", expectedTrailer, sessionsCommitMsg)
- } else {
- t.Logf("✓ Found trailer: %s", expectedTrailer)
- }
- } else {
- t.Logf("✓ Found trailer: %s", trailerKey)
- }
- }
-
- t.Log("Sessions branch commit trailers test completed successfully!")
-}
diff --git a/cli/integration_test/resume_2_test.go b/cli/integration_test/resume_2_test.go
new file mode 100644
index 0000000..20391a3
--- /dev/null
+++ b/cli/integration_test/resume_2_test.go
@@ -0,0 +1,400 @@
+//go:build integration
+
+package integration
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestResume_MultiSessionMixedTimestamps tests resume with multiple sessions in a checkpoint
+// where one session has a newer local log (conflict) and another doesn't (no conflict).
+func TestResume_MultiSessionMixedTimestamps(t *testing.T) {
+ t.Parallel()
+ env := NewFeatureBranchEnv(t)
+
+ // Create first session
+ session1 := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session1.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
+ }
+
+ content1 := "def hello; end"
+ env.WriteFile("hello.rb", content1)
+
+ session1.CreateTranscript(
+ "Create hello method",
+ []FileChange{{Path: "hello.rb", Content: content1}},
+ )
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop session1 failed: %v", err)
+ }
+
+ // Create second session (same base commit, different session)
+ session2 := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session2.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
+ }
+
+ content2 := "def goodbye; end"
+ env.WriteFile("goodbye.rb", content2)
+
+ session2.CreateTranscript(
+ "Create goodbye method",
+ []FileChange{{Path: "goodbye.rb", Content: content2}},
+ )
+ if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop session2 failed: %v", err)
+ }
+
+ // Commit changes with hooks (this triggers prepare-commit-msg and post-commit hooks,
+ // which adds Trace-Checkpoint trailer and condenses both sessions to the same checkpoint)
+ env.GitCommitWithShadowHooks("Add hello and goodbye methods", "hello.rb", "goodbye.rb")
+
+ featureBranch := env.GetCurrentBranch()
+
+ // Create local logs with different timestamps:
+ // - session1: NEWER than checkpoint (conflict)
+ // - session2: OLDER than checkpoint (no conflict)
+ if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil {
+ t.Fatalf("failed to create Claude project dir: %v", err)
+ }
+
+ // Session 1: newer local log (conflict)
+ log1Path := filepath.Join(env.ClaudeProjectDir, session1.ID+".jsonl")
+ futureTimestamp := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
+ newerContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"newer local work on session1"}}`, futureTimestamp)
+ if err := os.WriteFile(log1Path, []byte(newerContent), 0o644); err != nil {
+ t.Fatalf("failed to write session1 log: %v", err)
+ }
+
+ // Session 2: older local log (no conflict)
+ log2Path := filepath.Join(env.ClaudeProjectDir, session2.ID+".jsonl")
+ pastTimestamp := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
+ olderContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"older local work on session2"}}`, pastTimestamp)
+ if err := os.WriteFile(log2Path, []byte(olderContent), 0o644); err != nil {
+ t.Fatalf("failed to write session2 log: %v", err)
+ }
+
+ // Switch to main
+ env.GitCheckoutBranch(masterBranch)
+
+ // Resume WITH --force (to bypass confirmation for the conflict)
+ output, err := env.RunResumeForce(featureBranch)
+ if err != nil {
+ t.Fatalf("resume --force failed: %v\nOutput: %s", err, output)
+ }
+
+ // Both logs should be overwritten with checkpoint content
+ data1, err := os.ReadFile(log1Path)
+ if err != nil {
+ t.Fatalf("failed to read session1 log: %v", err)
+ }
+ if strings.Contains(string(data1), "newer local work") {
+ t.Errorf("session1 log should have been overwritten, but still has newer content: %s", string(data1))
+ }
+ if !strings.Contains(string(data1), "Create hello method") {
+ t.Errorf("session1 log should contain checkpoint transcript, got: %s", string(data1))
+ }
+
+ data2, err := os.ReadFile(log2Path)
+ if err != nil {
+ t.Fatalf("failed to read session2 log: %v", err)
+ }
+ if strings.Contains(string(data2), "older local work") {
+ t.Errorf("session2 log should have been overwritten, but still has older content: %s", string(data2))
+ }
+ if !strings.Contains(string(data2), "Create goodbye method") {
+ t.Errorf("session2 log should contain checkpoint transcript, got: %s", string(data2))
+ }
+
+ // Output should mention restoring multiple sessions
+ if !strings.Contains(output, "Restoring 2 sessions") {
+ t.Logf("Note: Expected 'Restoring 2 sessions' in output, got: %s", output)
+ }
+}
+
+// TestResume_LocalLogNoTimestamp tests that when local log has no valid timestamp,
+// resume proceeds without requiring --force (treated as new).
+func TestResume_LocalLogNoTimestamp(t *testing.T) {
+ t.Parallel()
+ env := NewFeatureBranchEnv(t)
+
+ // Create a session
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
+ }
+
+ content := "def hello; end"
+ env.WriteFile("hello.rb", content)
+
+ session.CreateTranscript(
+ "Create hello method",
+ []FileChange{{Path: "hello.rb", Content: content}},
+ )
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // Commit the session's changes (manual-commit requires user to commit)
+ env.GitCommitWithShadowHooks("Create hello method", "hello.rb")
+
+ featureBranch := env.GetCurrentBranch()
+
+ // Create a local log WITHOUT a valid timestamp (can't be parsed)
+ if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil {
+ t.Fatalf("failed to create Claude project dir: %v", err)
+ }
+ existingLog := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl")
+ // Content without timestamp field - should be treated as "new"
+ noTimestampContent := `{"type":"human","message":{"content":"no timestamp"}}`
+ if err := os.WriteFile(existingLog, []byte(noTimestampContent), 0o644); err != nil {
+ t.Fatalf("failed to write existing log: %v", err)
+ }
+
+ // Switch to main
+ env.GitCheckoutBranch(masterBranch)
+
+ // Resume WITHOUT --force should succeed (no timestamp = treated as new)
+ output, err := env.RunResume(featureBranch)
+ if err != nil {
+ t.Fatalf("resume failed (should succeed when local has no timestamp): %v\nOutput: %s", err, output)
+ }
+
+ // Verify local log was overwritten with checkpoint content
+ data, err := os.ReadFile(existingLog)
+ if err != nil {
+ t.Fatalf("failed to read log: %v", err)
+ }
+ if strings.Contains(string(data), "no timestamp") {
+ t.Errorf("local log should have been overwritten, but still has old content: %s", string(data))
+ }
+ if !strings.Contains(string(data), "Create hello method") {
+ t.Errorf("restored log should contain checkpoint transcript, got: %s", string(data))
+ }
+}
+
+// TestResume_SquashMergeMultipleCheckpoints tests resume when a squash merge commit
+// contains multiple Trace-Checkpoint trailers from different sessions/commits.
+// This simulates the GitHub squash merge workflow where:
+// 1. Developer creates feature branch with multiple commits, each with its own checkpoint
+// 2. PR is squash-merged to main, combining all commit messages (and their checkpoint trailers)
+// 3. Feature branch is deleted
+// 4. Running "trace resume main" should resume only from the latest checkpoint (most recent session)
+func TestResume_SquashMergeMultipleCheckpoints(t *testing.T) {
+ t.Parallel()
+ env := NewFeatureBranchEnv(t)
+
+ // === Session 1: First piece of work on feature branch ===
+ session1 := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session1.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit session1 failed: %v", err)
+ }
+
+ content1 := "puts 'hello world'"
+ env.WriteFile("hello.rb", content1)
+
+ session1.CreateTranscript(
+ "Create hello script",
+ []FileChange{{Path: "hello.rb", Content: content1}},
+ )
+ if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop session1 failed: %v", err)
+ }
+
+ // Commit session 1 (triggers condensation → checkpoint 1 on trace/checkpoints/v1)
+ env.GitCommitWithShadowHooks("Create hello script", "hello.rb")
+ checkpointID1 := env.GetLatestCheckpointID()
+ t.Logf("Session 1 checkpoint: %s", checkpointID1)
+
+ // === Session 2: Second piece of work on feature branch ===
+ session2 := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session2.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit session2 failed: %v", err)
+ }
+
+ content2 := "puts 'goodbye world'"
+ env.WriteFile("goodbye.rb", content2)
+
+ session2.CreateTranscript(
+ "Create goodbye script",
+ []FileChange{{Path: "goodbye.rb", Content: content2}},
+ )
+ if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop session2 failed: %v", err)
+ }
+
+ // Commit session 2 (triggers condensation → checkpoint 2 on trace/checkpoints/v1)
+ env.GitCommitWithShadowHooks("Create goodbye script", "goodbye.rb")
+ checkpointID2 := env.GetLatestCheckpointID()
+ t.Logf("Session 2 checkpoint: %s", checkpointID2)
+
+ // Verify we got two different checkpoint IDs
+ if checkpointID1 == checkpointID2 {
+ t.Fatalf("expected different checkpoint IDs, got same: %s", checkpointID1)
+ }
+
+ // === Simulate squash merge: switch to master, create squash commit ===
+ env.GitCheckoutBranch(masterBranch)
+
+ // Write the combined file changes (as if squash merged)
+ env.WriteFile("hello.rb", content1)
+ env.WriteFile("goodbye.rb", content2)
+ env.GitAdd("hello.rb")
+ env.GitAdd("goodbye.rb")
+
+ // Create squash merge commit with both checkpoint trailers in the message
+ // This mimics GitHub's squash merge format: PR title + individual commit messages
+ env.GitCommitWithMultipleCheckpoints(
+ "Feature branch (#1)\n\n* Create hello script\n\n* Create goodbye script",
+ []string{checkpointID1, checkpointID2},
+ )
+
+ // Remove local session logs (simulating a fresh machine or deleted local state)
+ if err := os.RemoveAll(env.ClaudeProjectDir); err != nil {
+ t.Fatalf("failed to remove Claude project dir: %v", err)
+ }
+
+ // === Run resume on master ===
+ output, err := env.RunResume(masterBranch)
+ if err != nil {
+ t.Fatalf("resume failed: %v\nOutput: %s", err, output)
+ }
+
+ t.Logf("Resume output:\n%s", output)
+
+ // Should show info about skipped checkpoints
+ if !strings.Contains(output, "older checkpoints skipped") {
+ t.Errorf("expected 'older checkpoints skipped' in output, got: %s", output)
+ }
+
+ // Should only resume the latest session (session2), not session1
+ if strings.Contains(output, session1.ID) {
+ t.Errorf("session1 ID %s should NOT appear in output (older checkpoint was skipped), got: %s", session1.ID, output)
+ }
+ if !strings.Contains(output, session2.ID) {
+ t.Errorf("expected session2 ID %s in output, got: %s", session2.ID, output)
+ }
+
+ // Should contain claude -r command
+ if !strings.Contains(output, "claude -r") {
+ t.Errorf("expected 'claude -r' in output, got: %s", output)
+ }
+}
+
+// TestResume_RelocatedRepo tests that resume works when a repository is moved
+// to a different directory after checkpoint creation. This validates that resume
+// reads checkpoint data from the git metadata branch (which travels with the repo)
+// and writes transcripts to the current project dir, not any stored path from
+// checkpoint creation time.
+func TestResume_RelocatedRepo(t *testing.T) {
+ t.Parallel()
+ env := NewFeatureBranchEnv(t)
+
+ // Create a session on the feature branch
+ session := env.NewSession()
+ if err := env.SimulateUserPromptSubmit(session.ID); err != nil {
+ t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
+ }
+
+ content := "puts 'Hello from session'"
+ env.WriteFile("hello.rb", content)
+
+ session.CreateTranscript(
+ "Create a hello script",
+ []FileChange{{Path: "hello.rb", Content: content}},
+ )
+ if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
+ t.Fatalf("SimulateStop failed: %v", err)
+ }
+
+ // Commit the file (manual-commit requires user to commit with hooks)
+ env.GitCommitWithShadowHooks("Create a hello script", "hello.rb")
+
+ featureBranch := env.GetCurrentBranch()
+ originalClaudeProjectDir := env.ClaudeProjectDir
+
+ // Switch to master before moving the repo
+ env.GitCheckoutBranch(masterBranch)
+
+ // Move the repository to a completely different location
+ newBase := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(newBase); err == nil {
+ newBase = resolved
+ }
+ newRepoDir := filepath.Join(newBase, "relocated", "new-location", "test-repo")
+ if err := os.MkdirAll(filepath.Dir(newRepoDir), 0o755); err != nil {
+ t.Fatalf("failed to create parent dir: %v", err)
+ }
+ if err := os.Rename(env.RepoDir, newRepoDir); err != nil {
+ t.Fatalf("failed to move repo: %v", err)
+ }
+
+ // Verify original location no longer exists
+ if _, err := os.Stat(env.RepoDir); !os.IsNotExist(err) {
+ t.Fatalf("original repo dir should not exist after move")
+ }
+ t.Logf("Moved repo from %s to %s", env.RepoDir, newRepoDir)
+
+ // Create a fresh Claude project dir for the new location
+ newClaudeProjectDir := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(newClaudeProjectDir); err == nil {
+ newClaudeProjectDir = resolved
+ }
+
+ // Create a new TestEnv pointing at the relocated repo
+ newEnv := &TestEnv{
+ T: t,
+ RepoDir: newRepoDir,
+ ClaudeProjectDir: newClaudeProjectDir,
+ }
+
+ // Run resume in the relocated repo with --force to bypass any timestamp checks
+ output, err := newEnv.RunResumeForce(featureBranch)
+ if err != nil {
+ t.Fatalf("resume in relocated repo failed: %v\nOutput: %s", err, output)
+ }
+ t.Logf("Resume output:\n%s", output)
+
+ // Verify we switched to the feature branch
+ if branch := newEnv.GetCurrentBranch(); branch != featureBranch {
+ t.Errorf("expected to be on %s, got %s", featureBranch, branch)
+ }
+
+ // Verify transcript was restored to the NEW Claude project dir
+ transcriptFiles, err := filepath.Glob(filepath.Join(newClaudeProjectDir, "*.jsonl"))
+ if err != nil {
+ t.Fatalf("failed to glob transcript files: %v", err)
+ }
+ if len(transcriptFiles) == 0 {
+ t.Fatal("expected transcript file to be restored to new Claude project dir")
+ }
+
+ // Verify the transcript contains the original session content
+ data, err := os.ReadFile(transcriptFiles[0])
+ if err != nil {
+ t.Fatalf("failed to read restored transcript: %v", err)
+ }
+ if !strings.Contains(string(data), "Create a hello script") {
+ t.Errorf("restored transcript should contain session content, got: %s", string(data))
+ }
+
+ // Verify the OLD Claude project dir was NOT written to by resume
+ oldTranscriptFiles, err := filepath.Glob(filepath.Join(originalClaudeProjectDir, "*.jsonl"))
+ if err != nil {
+ t.Fatalf("failed to glob old transcript files: %v", err)
+ }
+ if len(oldTranscriptFiles) > 0 {
+ t.Errorf("old Claude project dir should not have transcript files after resume, but found %d", len(oldTranscriptFiles))
+ }
+
+ // Verify output contains session info
+ if !strings.Contains(output, "Restored session") {
+ t.Errorf("output should contain 'Restored session', got: %s", output)
+ }
+}
diff --git a/cli/integration_test/resume_test.go b/cli/integration_test/resume_test.go
index 31f302b..9ed8624 100644
--- a/cli/integration_test/resume_test.go
+++ b/cli/integration_test/resume_test.go
@@ -761,391 +761,3 @@ func TestResume_CheckpointNewerTimestamp(t *testing.T) {
t.Errorf("restored log should contain checkpoint transcript, got: %s", string(data))
}
}
-
-// TestResume_MultiSessionMixedTimestamps tests resume with multiple sessions in a checkpoint
-// where one session has a newer local log (conflict) and another doesn't (no conflict).
-func TestResume_MultiSessionMixedTimestamps(t *testing.T) {
- t.Parallel()
- env := NewFeatureBranchEnv(t)
-
- // Create first session
- session1 := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session1.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
- }
-
- content1 := "def hello; end"
- env.WriteFile("hello.rb", content1)
-
- session1.CreateTranscript(
- "Create hello method",
- []FileChange{{Path: "hello.rb", Content: content1}},
- )
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop session1 failed: %v", err)
- }
-
- // Create second session (same base commit, different session)
- session2 := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session2.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
- }
-
- content2 := "def goodbye; end"
- env.WriteFile("goodbye.rb", content2)
-
- session2.CreateTranscript(
- "Create goodbye method",
- []FileChange{{Path: "goodbye.rb", Content: content2}},
- )
- if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop session2 failed: %v", err)
- }
-
- // Commit changes with hooks (this triggers prepare-commit-msg and post-commit hooks,
- // which adds Trace-Checkpoint trailer and condenses both sessions to the same checkpoint)
- env.GitCommitWithShadowHooks("Add hello and goodbye methods", "hello.rb", "goodbye.rb")
-
- featureBranch := env.GetCurrentBranch()
-
- // Create local logs with different timestamps:
- // - session1: NEWER than checkpoint (conflict)
- // - session2: OLDER than checkpoint (no conflict)
- if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil {
- t.Fatalf("failed to create Claude project dir: %v", err)
- }
-
- // Session 1: newer local log (conflict)
- log1Path := filepath.Join(env.ClaudeProjectDir, session1.ID+".jsonl")
- futureTimestamp := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
- newerContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"newer local work on session1"}}`, futureTimestamp)
- if err := os.WriteFile(log1Path, []byte(newerContent), 0o644); err != nil {
- t.Fatalf("failed to write session1 log: %v", err)
- }
-
- // Session 2: older local log (no conflict)
- log2Path := filepath.Join(env.ClaudeProjectDir, session2.ID+".jsonl")
- pastTimestamp := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
- olderContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"older local work on session2"}}`, pastTimestamp)
- if err := os.WriteFile(log2Path, []byte(olderContent), 0o644); err != nil {
- t.Fatalf("failed to write session2 log: %v", err)
- }
-
- // Switch to main
- env.GitCheckoutBranch(masterBranch)
-
- // Resume WITH --force (to bypass confirmation for the conflict)
- output, err := env.RunResumeForce(featureBranch)
- if err != nil {
- t.Fatalf("resume --force failed: %v\nOutput: %s", err, output)
- }
-
- // Both logs should be overwritten with checkpoint content
- data1, err := os.ReadFile(log1Path)
- if err != nil {
- t.Fatalf("failed to read session1 log: %v", err)
- }
- if strings.Contains(string(data1), "newer local work") {
- t.Errorf("session1 log should have been overwritten, but still has newer content: %s", string(data1))
- }
- if !strings.Contains(string(data1), "Create hello method") {
- t.Errorf("session1 log should contain checkpoint transcript, got: %s", string(data1))
- }
-
- data2, err := os.ReadFile(log2Path)
- if err != nil {
- t.Fatalf("failed to read session2 log: %v", err)
- }
- if strings.Contains(string(data2), "older local work") {
- t.Errorf("session2 log should have been overwritten, but still has older content: %s", string(data2))
- }
- if !strings.Contains(string(data2), "Create goodbye method") {
- t.Errorf("session2 log should contain checkpoint transcript, got: %s", string(data2))
- }
-
- // Output should mention restoring multiple sessions
- if !strings.Contains(output, "Restoring 2 sessions") {
- t.Logf("Note: Expected 'Restoring 2 sessions' in output, got: %s", output)
- }
-}
-
-// TestResume_LocalLogNoTimestamp tests that when local log has no valid timestamp,
-// resume proceeds without requiring --force (treated as new).
-func TestResume_LocalLogNoTimestamp(t *testing.T) {
- t.Parallel()
- env := NewFeatureBranchEnv(t)
-
- // Create a session
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
- }
-
- content := "def hello; end"
- env.WriteFile("hello.rb", content)
-
- session.CreateTranscript(
- "Create hello method",
- []FileChange{{Path: "hello.rb", Content: content}},
- )
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // Commit the session's changes (manual-commit requires user to commit)
- env.GitCommitWithShadowHooks("Create hello method", "hello.rb")
-
- featureBranch := env.GetCurrentBranch()
-
- // Create a local log WITHOUT a valid timestamp (can't be parsed)
- if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil {
- t.Fatalf("failed to create Claude project dir: %v", err)
- }
- existingLog := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl")
- // Content without timestamp field - should be treated as "new"
- noTimestampContent := `{"type":"human","message":{"content":"no timestamp"}}`
- if err := os.WriteFile(existingLog, []byte(noTimestampContent), 0o644); err != nil {
- t.Fatalf("failed to write existing log: %v", err)
- }
-
- // Switch to main
- env.GitCheckoutBranch(masterBranch)
-
- // Resume WITHOUT --force should succeed (no timestamp = treated as new)
- output, err := env.RunResume(featureBranch)
- if err != nil {
- t.Fatalf("resume failed (should succeed when local has no timestamp): %v\nOutput: %s", err, output)
- }
-
- // Verify local log was overwritten with checkpoint content
- data, err := os.ReadFile(existingLog)
- if err != nil {
- t.Fatalf("failed to read log: %v", err)
- }
- if strings.Contains(string(data), "no timestamp") {
- t.Errorf("local log should have been overwritten, but still has old content: %s", string(data))
- }
- if !strings.Contains(string(data), "Create hello method") {
- t.Errorf("restored log should contain checkpoint transcript, got: %s", string(data))
- }
-}
-
-// TestResume_SquashMergeMultipleCheckpoints tests resume when a squash merge commit
-// contains multiple Trace-Checkpoint trailers from different sessions/commits.
-// This simulates the GitHub squash merge workflow where:
-// 1. Developer creates feature branch with multiple commits, each with its own checkpoint
-// 2. PR is squash-merged to main, combining all commit messages (and their checkpoint trailers)
-// 3. Feature branch is deleted
-// 4. Running "trace resume main" should resume only from the latest checkpoint (most recent session)
-func TestResume_SquashMergeMultipleCheckpoints(t *testing.T) {
- t.Parallel()
- env := NewFeatureBranchEnv(t)
-
- // === Session 1: First piece of work on feature branch ===
- session1 := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session1.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit session1 failed: %v", err)
- }
-
- content1 := "puts 'hello world'"
- env.WriteFile("hello.rb", content1)
-
- session1.CreateTranscript(
- "Create hello script",
- []FileChange{{Path: "hello.rb", Content: content1}},
- )
- if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop session1 failed: %v", err)
- }
-
- // Commit session 1 (triggers condensation → checkpoint 1 on trace/checkpoints/v1)
- env.GitCommitWithShadowHooks("Create hello script", "hello.rb")
- checkpointID1 := env.GetLatestCheckpointID()
- t.Logf("Session 1 checkpoint: %s", checkpointID1)
-
- // === Session 2: Second piece of work on feature branch ===
- session2 := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session2.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit session2 failed: %v", err)
- }
-
- content2 := "puts 'goodbye world'"
- env.WriteFile("goodbye.rb", content2)
-
- session2.CreateTranscript(
- "Create goodbye script",
- []FileChange{{Path: "goodbye.rb", Content: content2}},
- )
- if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop session2 failed: %v", err)
- }
-
- // Commit session 2 (triggers condensation → checkpoint 2 on trace/checkpoints/v1)
- env.GitCommitWithShadowHooks("Create goodbye script", "goodbye.rb")
- checkpointID2 := env.GetLatestCheckpointID()
- t.Logf("Session 2 checkpoint: %s", checkpointID2)
-
- // Verify we got two different checkpoint IDs
- if checkpointID1 == checkpointID2 {
- t.Fatalf("expected different checkpoint IDs, got same: %s", checkpointID1)
- }
-
- // === Simulate squash merge: switch to master, create squash commit ===
- env.GitCheckoutBranch(masterBranch)
-
- // Write the combined file changes (as if squash merged)
- env.WriteFile("hello.rb", content1)
- env.WriteFile("goodbye.rb", content2)
- env.GitAdd("hello.rb")
- env.GitAdd("goodbye.rb")
-
- // Create squash merge commit with both checkpoint trailers in the message
- // This mimics GitHub's squash merge format: PR title + individual commit messages
- env.GitCommitWithMultipleCheckpoints(
- "Feature branch (#1)\n\n* Create hello script\n\n* Create goodbye script",
- []string{checkpointID1, checkpointID2},
- )
-
- // Remove local session logs (simulating a fresh machine or deleted local state)
- if err := os.RemoveAll(env.ClaudeProjectDir); err != nil {
- t.Fatalf("failed to remove Claude project dir: %v", err)
- }
-
- // === Run resume on master ===
- output, err := env.RunResume(masterBranch)
- if err != nil {
- t.Fatalf("resume failed: %v\nOutput: %s", err, output)
- }
-
- t.Logf("Resume output:\n%s", output)
-
- // Should show info about skipped checkpoints
- if !strings.Contains(output, "older checkpoints skipped") {
- t.Errorf("expected 'older checkpoints skipped' in output, got: %s", output)
- }
-
- // Should only resume the latest session (session2), not session1
- if strings.Contains(output, session1.ID) {
- t.Errorf("session1 ID %s should NOT appear in output (older checkpoint was skipped), got: %s", session1.ID, output)
- }
- if !strings.Contains(output, session2.ID) {
- t.Errorf("expected session2 ID %s in output, got: %s", session2.ID, output)
- }
-
- // Should contain claude -r command
- if !strings.Contains(output, "claude -r") {
- t.Errorf("expected 'claude -r' in output, got: %s", output)
- }
-}
-
-// TestResume_RelocatedRepo tests that resume works when a repository is moved
-// to a different directory after checkpoint creation. This validates that resume
-// reads checkpoint data from the git metadata branch (which travels with the repo)
-// and writes transcripts to the current project dir, not any stored path from
-// checkpoint creation time.
-func TestResume_RelocatedRepo(t *testing.T) {
- t.Parallel()
- env := NewFeatureBranchEnv(t)
-
- // Create a session on the feature branch
- session := env.NewSession()
- if err := env.SimulateUserPromptSubmit(session.ID); err != nil {
- t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
- }
-
- content := "puts 'Hello from session'"
- env.WriteFile("hello.rb", content)
-
- session.CreateTranscript(
- "Create a hello script",
- []FileChange{{Path: "hello.rb", Content: content}},
- )
- if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
- t.Fatalf("SimulateStop failed: %v", err)
- }
-
- // Commit the file (manual-commit requires user to commit with hooks)
- env.GitCommitWithShadowHooks("Create a hello script", "hello.rb")
-
- featureBranch := env.GetCurrentBranch()
- originalClaudeProjectDir := env.ClaudeProjectDir
-
- // Switch to master before moving the repo
- env.GitCheckoutBranch(masterBranch)
-
- // Move the repository to a completely different location
- newBase := t.TempDir()
- if resolved, err := filepath.EvalSymlinks(newBase); err == nil {
- newBase = resolved
- }
- newRepoDir := filepath.Join(newBase, "relocated", "new-location", "test-repo")
- if err := os.MkdirAll(filepath.Dir(newRepoDir), 0o755); err != nil {
- t.Fatalf("failed to create parent dir: %v", err)
- }
- if err := os.Rename(env.RepoDir, newRepoDir); err != nil {
- t.Fatalf("failed to move repo: %v", err)
- }
-
- // Verify original location no longer exists
- if _, err := os.Stat(env.RepoDir); !os.IsNotExist(err) {
- t.Fatalf("original repo dir should not exist after move")
- }
- t.Logf("Moved repo from %s to %s", env.RepoDir, newRepoDir)
-
- // Create a fresh Claude project dir for the new location
- newClaudeProjectDir := t.TempDir()
- if resolved, err := filepath.EvalSymlinks(newClaudeProjectDir); err == nil {
- newClaudeProjectDir = resolved
- }
-
- // Create a new TestEnv pointing at the relocated repo
- newEnv := &TestEnv{
- T: t,
- RepoDir: newRepoDir,
- ClaudeProjectDir: newClaudeProjectDir,
- }
-
- // Run resume in the relocated repo with --force to bypass any timestamp checks
- output, err := newEnv.RunResumeForce(featureBranch)
- if err != nil {
- t.Fatalf("resume in relocated repo failed: %v\nOutput: %s", err, output)
- }
- t.Logf("Resume output:\n%s", output)
-
- // Verify we switched to the feature branch
- if branch := newEnv.GetCurrentBranch(); branch != featureBranch {
- t.Errorf("expected to be on %s, got %s", featureBranch, branch)
- }
-
- // Verify transcript was restored to the NEW Claude project dir
- transcriptFiles, err := filepath.Glob(filepath.Join(newClaudeProjectDir, "*.jsonl"))
- if err != nil {
- t.Fatalf("failed to glob transcript files: %v", err)
- }
- if len(transcriptFiles) == 0 {
- t.Fatal("expected transcript file to be restored to new Claude project dir")
- }
-
- // Verify the transcript contains the original session content
- data, err := os.ReadFile(transcriptFiles[0])
- if err != nil {
- t.Fatalf("failed to read restored transcript: %v", err)
- }
- if !strings.Contains(string(data), "Create a hello script") {
- t.Errorf("restored transcript should contain session content, got: %s", string(data))
- }
-
- // Verify the OLD Claude project dir was NOT written to by resume
- oldTranscriptFiles, err := filepath.Glob(filepath.Join(originalClaudeProjectDir, "*.jsonl"))
- if err != nil {
- t.Fatalf("failed to glob old transcript files: %v", err)
- }
- if len(oldTranscriptFiles) > 0 {
- t.Errorf("old Claude project dir should not have transcript files after resume, but found %d", len(oldTranscriptFiles))
- }
-
- // Verify output contains session info
- if !strings.Contains(output, "Restored session") {
- t.Errorf("output should contain 'Restored session', got: %s", output)
- }
-}
diff --git a/cli/integration_test/testenv.go b/cli/integration_test/testenv.go
index 01cf1b6..9659012 100644
--- a/cli/integration_test/testenv.go
+++ b/cli/integration_test/testenv.go
@@ -4,31 +4,24 @@ package integration
import (
"context"
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
"errors"
- "fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
- "runtime"
"strings"
"testing"
"time"
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint"
- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/execx"
"github.com/GrayCodeAI/trace/cli/jsonutil"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/testutil"
- "github.com/GrayCodeAI/trace/cli/trailers"
"github.com/go-git/go-git/v6"
- "github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/format/config"
"github.com/go-git/go-git/v6/plumbing/object"
)
@@ -819,1286 +812,3 @@ func (env *TestEnv) RewindLogsOnly(commitID string) error {
env.T.Logf("Rewind logs-only output: %s", output)
return nil
}
-
-// RewindReset performs a reset rewind using the CLI.
-// This resets the branch to the specified commit (destructive).
-func (env *TestEnv) RewindReset(commitID string) error {
- env.T.Helper()
-
- // Run rewind --to --reset using the shared binary
- cmd := exec.Command(getTestBinary(), "checkpoint", "rewind", "--to", commitID, "--reset")
- cmd.Dir = env.RepoDir
- cmd.Env = env.cliEnv()
-
- output, err := cmd.CombinedOutput()
- if err != nil {
- return errors.New("rewind reset failed: " + string(output))
- }
-
- env.T.Logf("Rewind reset output: %s", output)
- return nil
-}
-
-// BranchExists checks if a branch exists in the repository.
-func (env *TestEnv) BranchExists(branchName string) bool {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- _, err = repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- return err == nil
-}
-
-// GetCommitMessage returns the commit message for the given commit hash.
-func (env *TestEnv) GetCommitMessage(hash string) string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- commitHash := plumbing.NewHash(hash)
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- env.T.Fatalf("failed to get commit %s: %v", hash, err)
- }
-
- return commit.Message
-}
-
-// FileExistsInBranch checks if a file exists in a specific branch's tree.
-func (env *TestEnv) FileExistsInBranch(branchName, filePath string) bool {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- // Get the branch reference
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- if err != nil {
- // Try as a remote-style ref
- ref, err = repo.Reference(plumbing.ReferenceName("refs/heads/"+branchName), true)
- if err != nil {
- return false
- }
- }
-
- // Get the commit
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return false
- }
-
- // Get the tree
- tree, err := commit.Tree()
- if err != nil {
- return false
- }
-
- // Check if file exists
- _, err = tree.File(filePath)
- return err == nil
-}
-
-// ReadFileFromBranch reads a file's content from a specific branch's tree.
-// Returns the content and true if found, empty string and false if not found.
-func (env *TestEnv) ReadFileFromBranch(branchName, filePath string) (string, bool) {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- // Get the branch reference
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- if err != nil {
- // Try as a remote-style ref
- ref, err = repo.Reference(plumbing.ReferenceName("refs/heads/"+branchName), true)
- if err != nil {
- return "", false
- }
- }
-
- // Get the commit
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return "", false
- }
-
- // Get the tree
- tree, err := commit.Tree()
- if err != nil {
- return "", false
- }
-
- // Get the file
- file, err := tree.File(filePath)
- if err != nil {
- return "", false
- }
-
- // Get the content
- content, err := file.Contents()
- if err != nil {
- return "", false
- }
-
- return content, true
-}
-
-// ReadFileFromRef reads a file's content from a specific ref's tree.
-// Unlike ReadFileFromBranch, this takes a full ref name (e.g., "refs/trace/checkpoints/v2/main")
-// and does not prepend "refs/heads/".
-// Returns the content and true if found, empty string and false if not found.
-func (env *TestEnv) ReadFileFromRef(refName, filePath string) (string, bool) {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- ref, err := repo.Reference(plumbing.ReferenceName(refName), true)
- if err != nil {
- return "", false
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return "", false
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return "", false
- }
-
- file, err := tree.File(filePath)
- if err != nil {
- return "", false
- }
-
- content, err := file.Contents()
- if err != nil {
- return "", false
- }
-
- return content, true
-}
-
-// AssertCheckpointContainsSession verifies that the checkpoint summary includes
-// a session with the given session ID by reading per-session metadata from the
-// metadata branch.
-func (env *TestEnv) AssertCheckpointContainsSession(t *testing.T, summary checkpoint.CheckpointSummary, sessionID string) {
- t.Helper()
- for _, s := range summary.Sessions {
- if env.sessionMetadataMatchesID(s.Metadata, sessionID) {
- return
- }
- }
- t.Errorf("Checkpoint did not include session %q", sessionID)
-}
-
-// AssertCheckpointExcludesSession verifies that the checkpoint summary does NOT
-// include a session with the given session ID.
-func (env *TestEnv) AssertCheckpointExcludesSession(t *testing.T, summary checkpoint.CheckpointSummary, sessionID string) {
- t.Helper()
- for _, s := range summary.Sessions {
- if env.sessionMetadataMatchesID(s.Metadata, sessionID) {
- t.Errorf("Checkpoint incorrectly included session %q", sessionID)
- return
- }
- }
-}
-
-// sessionMetadataMatchesID reads session metadata from the metadata branch and
-// checks if it belongs to the given session ID.
-func (env *TestEnv) sessionMetadataMatchesID(metadataPath, sessionID string) bool {
- // Strip leading slash — git tree paths are relative
- cleanPath := strings.TrimPrefix(metadataPath, "/")
- content, found := env.ReadFileFromBranch(paths.MetadataBranchName, cleanPath)
- if !found {
- return false
- }
- var meta checkpoint.CommittedMetadata
- if err := json.Unmarshal([]byte(content), &meta); err != nil {
- return false
- }
- return meta.SessionID == sessionID
-}
-
-// RefExists checks if a ref exists in the repository.
-func (env *TestEnv) RefExists(refName string) bool {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- _, err = repo.Reference(plumbing.ReferenceName(refName), true)
- return err == nil
-}
-
-// GetLatestCommitMessageOnBranch returns the commit message of the latest commit on the given branch.
-func (env *TestEnv) GetLatestCommitMessageOnBranch(branchName string) string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- // Get the branch reference
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- if err != nil {
- env.T.Fatalf("failed to get branch %s reference: %v", branchName, err)
- }
-
- // Get the commit
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- env.T.Fatalf("failed to get commit object: %v", err)
- }
-
- return commit.Message
-}
-
-// GitCommitWithShadowHooks stages and commits files, simulating the prepare-commit-msg
-// and post-commit hooks as a human (with TTY). This is the default for tests.
-func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) {
- env.T.Helper()
- env.gitCommitWithShadowHooks(message, true, files...)
-}
-
-// GitCommitWithShadowHooksAsAgent is like GitCommitWithShadowHooks but simulates
-// an agent commit (no TTY). This triggers the fast path in PrepareCommitMsg that
-// skips content detection and interactive prompts for ACTIVE sessions.
-func (env *TestEnv) GitCommitWithShadowHooksAsAgent(message string, files ...string) {
- env.T.Helper()
- env.gitCommitWithShadowHooks(message, false, files...)
-}
-
-// prepareCommitMsgCmd builds the prepare-commit-msg hook command. When
-// simulateTTY is true, TRACE_TEST_TTY=1 forces interactive=true (an in-test
-// stand-in for a real terminal — Setsid can't synthesize a TTY). When false,
-// the child runs in a new session without a controlling terminal so its
-// /dev/tty probe fails and CanPromptInteractively() returns false.
-func (env *TestEnv) prepareCommitMsgCmd(simulateTTY bool, hookArgs ...string) *exec.Cmd {
- args := append([]string{"hooks", "git", "prepare-commit-msg"}, hookArgs...)
- var cmd *exec.Cmd
- if simulateTTY {
- cmd = exec.Command(getTestBinary(), args...)
- cmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
- } else {
- cmd = execx.NonInteractive(context.Background(), getTestBinary(), args...)
- cmd.Env = env.gitHookEnv()
- }
- cmd.Dir = env.RepoDir
- return cmd
-}
-
-// gitCommitWithShadowHooks is the shared implementation for committing with shadow hooks.
-func (env *TestEnv) gitCommitWithShadowHooks(message string, simulateTTY bool, files ...string) {
- env.T.Helper()
-
- // Stage files using go-git
- for _, file := range files {
- env.GitAdd(file)
- }
-
- // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
- msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
- if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
- env.T.Fatalf("failed to write commit message file: %v", err)
- }
-
- // Run prepare-commit-msg hook using the shared binary.
- // Pass source="message" to match real `git commit -m` behavior.
- prepCmd := env.prepareCommitMsgCmd(simulateTTY, msgFile, "message")
- if output, err := prepCmd.CombinedOutput(); err != nil {
- env.T.Logf("prepare-commit-msg output: %s", output)
- // Don't fail - hook may silently succeed
- }
-
- // Read the modified message
- modifiedMsg, err := os.ReadFile(msgFile)
- if err != nil {
- env.T.Fatalf("failed to read modified commit message: %v", err)
- }
-
- // Create the commit using go-git with the modified message
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- env.T.Fatalf("failed to get worktree: %v", err)
- }
-
- _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- if err != nil {
- env.T.Fatalf("failed to commit: %v", err)
- }
-
- // Run post-commit hook using the shared binary
- // This triggers condensation if the commit has an Trace-Checkpoint trailer
- postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
- postCmd.Dir = env.RepoDir
- postCmd.Env = env.gitHookEnv()
- if output, err := postCmd.CombinedOutput(); err != nil {
- env.T.Logf("post-commit output: %s", output)
- // Don't fail - hook may silently succeed
- }
-}
-
-func (env *TestEnv) gitHookEnv(extra ...string) []string {
- envVars := append(
- testutil.GitIsolatedEnv(),
- "TRACE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,
- "TRACE_TEST_OPENCODE_MOCK_EXPORT=1",
- )
- return append(envVars, extra...)
-}
-
-// GitCommitAmendWithShadowHooks amends the last commit with shadow hooks.
-// This simulates `git commit --amend` with the prepare-commit-msg and post-commit hooks.
-// The prepare-commit-msg hook is called with "commit" source to indicate an amend.
-func (env *TestEnv) GitCommitAmendWithShadowHooks(message string, files ...string) {
- env.T.Helper()
-
- // Stage any additional files
- for _, file := range files {
- env.GitAdd(file)
- }
-
- // Write commit message to temp file
- msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
- if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
- env.T.Fatalf("failed to write commit message file: %v", err)
- }
-
- // Run prepare-commit-msg hook with "commit" source (indicates amend).
- // Set TRACE_TEST_TTY=1 to simulate human (amend is always a human operation).
- prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "commit")
- prepCmd.Dir = env.RepoDir
- prepCmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
- if output, err := prepCmd.CombinedOutput(); err != nil {
- env.T.Logf("prepare-commit-msg (amend) output: %s", output)
- }
-
- // Read the modified message
- modifiedMsg, err := os.ReadFile(msgFile)
- if err != nil {
- env.T.Fatalf("failed to read modified commit message: %v", err)
- }
-
- // Amend the commit using go-git
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- env.T.Fatalf("failed to get worktree: %v", err)
- }
-
- _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- },
- Amend: true,
- })
- if err != nil {
- env.T.Fatalf("failed to amend commit: %v", err)
- }
-
- // Run post-commit hook
- postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
- postCmd.Dir = env.RepoDir
- postCmd.Env = env.gitHookEnv()
- if output, err := postCmd.CombinedOutput(); err != nil {
- env.T.Logf("post-commit (amend) output: %s", output)
- }
-}
-
-// GitPostRewriteWithShadowHooks runs the git post-rewrite hook with the provided
-// old->new commit mappings. Each mapping is a pair of commit SHAs.
-func (env *TestEnv) GitPostRewriteWithShadowHooks(rewriteType string, mappings ...[2]string) {
- env.T.Helper()
-
- var input strings.Builder
- for _, mapping := range mappings {
- input.WriteString(mapping[0])
- input.WriteByte(' ')
- input.WriteString(mapping[1])
- input.WriteByte('\n')
- }
-
- cmd := exec.Command(getTestBinary(), "hooks", "git", "post-rewrite", rewriteType)
- cmd.Dir = env.RepoDir
- cmd.Env = env.gitHookEnv()
- cmd.Stdin = strings.NewReader(input.String())
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("post-rewrite hook failed: %v\nOutput: %s", err, output)
- }
-}
-
-// GitCommitWithTrailerRemoved stages and commits files, simulating what happens when
-// a user removes the Trace-Checkpoint trailer during commit message editing.
-// This tests the opt-out behavior where removing the trailer skips condensation.
-func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) {
- env.T.Helper()
-
- // Stage files using go-git
- for _, file := range files {
- env.GitAdd(file)
- }
-
- // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
- msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
- if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
- env.T.Fatalf("failed to write commit message file: %v", err)
- }
-
- // Run prepare-commit-msg hook using the shared binary.
- // Set TRACE_TEST_TTY=1 to simulate human (this tests the editor flow where
- // the user removes the trailer before committing).
- prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile)
- prepCmd.Dir = env.RepoDir
- prepCmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
- if output, err := prepCmd.CombinedOutput(); err != nil {
- env.T.Logf("prepare-commit-msg output: %s", output)
- }
-
- // Read the modified message (with trailer added by hook)
- modifiedMsg, err := os.ReadFile(msgFile)
- if err != nil {
- env.T.Fatalf("failed to read modified commit message: %v", err)
- }
-
- // REMOVE the Trace-Checkpoint trailer (simulating user editing the message)
- lines := strings.Split(string(modifiedMsg), "\n")
- var cleanedLines []string
- for _, line := range lines {
- // Skip the trailer and the comments about it
- if strings.HasPrefix(line, "Trace-Checkpoint:") {
- continue
- }
- if strings.Contains(line, "Remove the Trace-Checkpoint trailer") {
- continue
- }
- if strings.Contains(line, "trailer will be added to your next commit") {
- continue
- }
- cleanedLines = append(cleanedLines, line)
- }
- cleanedMsg := strings.TrimRight(strings.Join(cleanedLines, "\n"), "\n") + "\n"
-
- // Create the commit using go-git with the cleaned message (no trailer)
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- env.T.Fatalf("failed to get worktree: %v", err)
- }
-
- _, err = worktree.Commit(cleanedMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- if err != nil {
- env.T.Fatalf("failed to commit: %v", err)
- }
-
- // Run post-commit hook - since trailer was removed, no condensation should happen
- postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
- postCmd.Dir = env.RepoDir
- postCmd.Env = env.gitHookEnv()
- if output, err := postCmd.CombinedOutput(); err != nil {
- env.T.Logf("post-commit output: %s", output)
- }
-}
-
-// GitRm stages file deletions using git rm.
-func (env *TestEnv) GitRm(paths ...string) {
- env.T.Helper()
-
- args := append([]string{"rm", "--"}, paths...)
- cmd := exec.Command("git", args...)
- cmd.Dir = env.RepoDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("git rm failed: %v\nOutput: %s", err, output)
- }
-}
-
-// GitCommitStagedWithShadowHooks commits whatever is already staged (without adding files first),
-// running the prepare-commit-msg and post-commit hooks like a real workflow.
-// Use this after GitRm or when files are already staged.
-func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) {
- env.T.Helper()
- env.gitCommitStagedWithShadowHooks(message, true)
-}
-
-// gitCommitStagedWithShadowHooks is the shared implementation for committing staged changes with hooks.
-func (env *TestEnv) gitCommitStagedWithShadowHooks(message string, simulateTTY bool) {
- env.T.Helper()
-
- // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
- msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
- if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
- env.T.Fatalf("failed to write commit message file: %v", err)
- }
-
- // Run prepare-commit-msg hook using the shared binary.
- prepCmd := env.prepareCommitMsgCmd(simulateTTY, msgFile, "message")
- if output, err := prepCmd.CombinedOutput(); err != nil {
- env.T.Logf("prepare-commit-msg output: %s", output)
- }
-
- // Read the modified message
- modifiedMsg, err := os.ReadFile(msgFile)
- if err != nil {
- env.T.Fatalf("failed to read modified commit message: %v", err)
- }
-
- // Create the commit using go-git with the modified message
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- env.T.Fatalf("failed to get worktree: %v", err)
- }
-
- _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- if err != nil {
- env.T.Fatalf("failed to commit: %v", err)
- }
-
- // Run post-commit hook
- postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
- postCmd.Dir = env.RepoDir
- postCmd.Env = env.gitHookEnv()
- if output, err := postCmd.CombinedOutput(); err != nil {
- env.T.Logf("post-commit output: %s", output)
- }
-}
-
-// ListBranchesWithPrefix returns all branches that start with the given prefix.
-func (env *TestEnv) ListBranchesWithPrefix(prefix string) []string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- refs, err := repo.References()
- if err != nil {
- env.T.Fatalf("failed to get references: %v", err)
- }
-
- var branches []string
- _ = refs.ForEach(func(ref *plumbing.Reference) error {
- name := ref.Name().Short()
- if len(name) >= len(prefix) && name[:len(prefix)] == prefix {
- branches = append(branches, name)
- }
- return nil
- })
-
- return branches
-}
-
-// GetLatestCheckpointID returns the most recent checkpoint ID from the trace/checkpoints/v1 branch.
-// This is used by tests that previously extracted the checkpoint ID from commit message trailers.
-// Now that active branch commits are clean (no trailers), we get the ID from the sessions branch.
-// Fatals if the checkpoint ID cannot be found, with detailed context about what was found.
-func (env *TestEnv) GetLatestCheckpointID() string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- // Get the trace/checkpoints/v1 branch
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- env.T.Fatalf("failed to get %s branch: %v", paths.MetadataBranchName, err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- env.T.Fatalf("failed to get commit: %v", err)
- }
-
- // Extract checkpoint ID from commit message
- // Format: "Checkpoint: <12-hex-char-id>\n\nSession: ...\nStrategy: ..."
- for _, line := range strings.Split(commit.Message, "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, "Checkpoint: ") {
- return strings.TrimPrefix(line, "Checkpoint: ")
- }
- }
-
- env.T.Fatalf("could not find checkpoint ID in %s branch commit message:\n%s",
- paths.MetadataBranchName, commit.Message)
- return ""
-}
-
-// TryGetLatestCheckpointID returns the most recent checkpoint ID from the trace/checkpoints/v1 branch.
-// Returns empty string if the branch doesn't exist or has no checkpoint commits yet.
-// Use this when you need to check if a checkpoint exists without failing the test.
-func (env *TestEnv) TryGetLatestCheckpointID() string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- return ""
- }
-
- // Get the trace/checkpoints/v1 branch
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- return ""
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return ""
- }
-
- // Extract checkpoint ID from commit message
- // Format: "Checkpoint: <12-hex-char-id>\n\nSession: ...\nStrategy: ..."
- for _, line := range strings.Split(commit.Message, "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, "Checkpoint: ") {
- return strings.TrimPrefix(line, "Checkpoint: ")
- }
- }
-
- return ""
-}
-
-// GetLatestCondensationID is an alias for GetLatestCheckpointID for backwards compatibility.
-func (env *TestEnv) GetLatestCondensationID() string {
- return env.GetLatestCheckpointID()
-}
-
-// GetCheckpointIDFromCommitMessage extracts the Trace-Checkpoint trailer from a commit message.
-// Returns empty string if no trailer found.
-func (env *TestEnv) GetCheckpointIDFromCommitMessage(commitSHA string) string {
- env.T.Helper()
-
- msg := env.GetCommitMessage(commitSHA)
- cpID, found := trailers.ParseCheckpoint(msg)
- if !found {
- return ""
- }
- return cpID.String()
-}
-
-// GetLatestCheckpointIDFromHistory walks backwards from HEAD on the active branch
-// and returns the checkpoint ID from the first commit that has an Trace-Checkpoint trailer.
-// This verifies that condensation actually happened (commit has trailer) without relying
-// on timestamp-based matching.
-func (env *TestEnv) GetLatestCheckpointIDFromHistory() string {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- head, err := repo.Head()
- if err != nil {
- env.T.Fatalf("failed to get HEAD: %v", err)
- }
-
- commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()})
- if err != nil {
- env.T.Fatalf("failed to iterate commits: %v", err)
- }
-
- var checkpointID string
- //nolint:errcheck // ForEach callback handles errors
- commitIter.ForEach(func(c *object.Commit) error {
- if cpID, found := trailers.ParseCheckpoint(c.Message); found {
- checkpointID = cpID.String()
- return errors.New("stop iteration") // Found it, stop
- }
- return nil
- })
-
- if checkpointID == "" {
- env.T.Fatalf("no commit with Trace-Checkpoint trailer found in history")
- }
-
- return checkpointID
-}
-
-// ShardedCheckpointPath returns the sharded path for a checkpoint ID.
-// Format: /
-// Delegates to id.CheckpointID.Path() for consistency.
-func ShardedCheckpointPath(checkpointID string) string {
- return id.CheckpointID(checkpointID).Path()
-}
-
-// SessionFilePath returns the path to a session file within a checkpoint.
-// Session files are stored in numbered subdirectories using 0-based indexing (e.g., 0/full.jsonl).
-// This function constructs the path for the first (default) session.
-func SessionFilePath(checkpointID string, fileName string) string {
- return id.CheckpointID(checkpointID).Path() + "/0/" + fileName
-}
-
-// CheckpointSummaryPath returns the path to the root metadata.json (CheckpointSummary) for a checkpoint.
-func CheckpointSummaryPath(checkpointID string) string {
- return id.CheckpointID(checkpointID).Path() + "/" + paths.MetadataFileName
-}
-
-// SessionMetadataPath returns the path to the session-level metadata.json for a checkpoint.
-func SessionMetadataPath(checkpointID string) string {
- return SessionFilePath(checkpointID, paths.MetadataFileName)
-}
-
-// CheckpointValidation contains expected values for checkpoint validation.
-type CheckpointValidation struct {
- // CheckpointID is the expected checkpoint ID
- CheckpointID string
-
- // SessionID is the expected session ID
- SessionID string
-
- // Strategy is the expected strategy name
- Strategy string
-
- // FilesTouched are the expected files in files_touched
- FilesTouched []string
-
- // ExpectedPrompts are strings that should appear in prompt.txt
- ExpectedPrompts []string
-
- // ExpectedTranscriptContent are strings that should appear in full.jsonl
- ExpectedTranscriptContent []string
-
- // CheckpointsCount is the expected checkpoint count (0 means don't validate)
- CheckpointsCount int
-}
-
-// ValidateCheckpoint performs comprehensive validation of a checkpoint on the metadata branch.
-// It validates:
-// - Root metadata.json (CheckpointSummary) structure and expected fields
-// - Session metadata.json (CommittedMetadata) structure and expected fields
-// - Transcript file (full.jsonl) is valid JSONL and contains expected content
-// - Content hash file (content_hash.txt) matches SHA256 of transcript
-// - Prompt file (prompt.txt) contains expected prompts
-func (env *TestEnv) ValidateCheckpoint(v CheckpointValidation) {
- env.T.Helper()
-
- // Validate root metadata.json (CheckpointSummary)
- env.validateCheckpointSummary(v)
-
- // Validate session metadata.json (CommittedMetadata)
- env.validateSessionMetadata(v)
-
- // Validate transcript is valid JSONL
- env.validateTranscriptJSONL(v.CheckpointID, v.ExpectedTranscriptContent)
-
- // Validate content hash matches transcript
- env.validateContentHash(v.CheckpointID)
-
- // Validate prompt.txt contains expected prompts
- if len(v.ExpectedPrompts) > 0 {
- env.validatePromptContent(v.CheckpointID, v.ExpectedPrompts)
- }
-}
-
-// validateCheckpointSummary validates the root metadata.json (CheckpointSummary).
-func (env *TestEnv) validateCheckpointSummary(v CheckpointValidation) {
- env.T.Helper()
-
- summaryPath := CheckpointSummaryPath(v.CheckpointID)
- content, found := env.ReadFileFromBranch(paths.MetadataBranchName, summaryPath)
- if !found {
- env.T.Fatalf("CheckpointSummary not found at %s", summaryPath)
- }
-
- var summary checkpoint.CheckpointSummary
- if err := json.Unmarshal([]byte(content), &summary); err != nil {
- env.T.Fatalf("Failed to parse CheckpointSummary: %v\nContent: %s", err, content)
- }
-
- // Validate checkpoint_id
- if summary.CheckpointID.String() != v.CheckpointID {
- env.T.Errorf("CheckpointSummary.CheckpointID = %q, want %q", summary.CheckpointID, v.CheckpointID)
- }
-
- // Validate strategy
- if v.Strategy != "" && summary.Strategy != v.Strategy {
- env.T.Errorf("CheckpointSummary.Strategy = %q, want %q", summary.Strategy, v.Strategy)
- }
-
- // Validate sessions array is populated
- if len(summary.Sessions) == 0 {
- env.T.Error("CheckpointSummary.Sessions should have at least one entry")
- }
-
- // Validate files_touched
- if len(v.FilesTouched) > 0 {
- touchedSet := make(map[string]bool)
- for _, f := range summary.FilesTouched {
- touchedSet[f] = true
- }
- for _, expected := range v.FilesTouched {
- if !touchedSet[expected] {
- env.T.Errorf("CheckpointSummary.FilesTouched missing %q, got %v", expected, summary.FilesTouched)
- }
- }
- }
-
- // Validate checkpoints_count
- if v.CheckpointsCount > 0 && summary.CheckpointsCount != v.CheckpointsCount {
- env.T.Errorf("CheckpointSummary.CheckpointsCount = %d, want %d", summary.CheckpointsCount, v.CheckpointsCount)
- }
-}
-
-// validateSessionMetadata validates the session-level metadata.json (CommittedMetadata).
-func (env *TestEnv) validateSessionMetadata(v CheckpointValidation) {
- env.T.Helper()
-
- metadataPath := SessionMetadataPath(v.CheckpointID)
- content, found := env.ReadFileFromBranch(paths.MetadataBranchName, metadataPath)
- if !found {
- env.T.Fatalf("Session metadata not found at %s", metadataPath)
- }
-
- var metadata checkpoint.CommittedMetadata
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- env.T.Fatalf("Failed to parse CommittedMetadata: %v\nContent: %s", err, content)
- }
-
- // Validate checkpoint_id
- if metadata.CheckpointID.String() != v.CheckpointID {
- env.T.Errorf("CommittedMetadata.CheckpointID = %q, want %q", metadata.CheckpointID, v.CheckpointID)
- }
-
- // Validate session_id
- if v.SessionID != "" && metadata.SessionID != v.SessionID {
- env.T.Errorf("CommittedMetadata.SessionID = %q, want %q", metadata.SessionID, v.SessionID)
- }
-
- // Validate strategy
- if v.Strategy != "" && metadata.Strategy != v.Strategy {
- env.T.Errorf("CommittedMetadata.Strategy = %q, want %q", metadata.Strategy, v.Strategy)
- }
-
- // Validate created_at is not zero
- if metadata.CreatedAt.IsZero() {
- env.T.Error("CommittedMetadata.CreatedAt should not be zero")
- }
-
- // Validate files_touched
- if len(v.FilesTouched) > 0 {
- touchedSet := make(map[string]bool)
- for _, f := range metadata.FilesTouched {
- touchedSet[f] = true
- }
- for _, expected := range v.FilesTouched {
- if !touchedSet[expected] {
- env.T.Errorf("CommittedMetadata.FilesTouched missing %q, got %v", expected, metadata.FilesTouched)
- }
- }
- }
-
- // Validate checkpoints_count
- if v.CheckpointsCount > 0 && metadata.CheckpointsCount != v.CheckpointsCount {
- env.T.Errorf("CommittedMetadata.CheckpointsCount = %d, want %d", metadata.CheckpointsCount, v.CheckpointsCount)
- }
-}
-
-// validateTranscriptJSONL validates that full.jsonl exists and is valid JSON or JSONL.
-// It supports both:
-// - JSON format (single document, used by OpenCode and Gemini CLI)
-// - JSONL format (one JSON object per line, used by Claude Code)
-func (env *TestEnv) validateTranscriptJSONL(checkpointID string, expectedContent []string) {
- env.T.Helper()
-
- transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName)
- content, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath)
- if !found {
- env.T.Fatalf("Transcript not found at %s", transcriptPath)
- }
-
- // First try to parse as a single JSON document (OpenCode/Gemini format)
- var jsonDoc any
- if err := json.Unmarshal([]byte(content), &jsonDoc); err == nil {
- // Valid JSON document - validation passed
- } else {
- // Fall back to JSONL validation (Claude Code format)
- lines := strings.Split(content, "\n")
- validLines := 0
- for i, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- validLines++
- var obj map[string]any
- if err := json.Unmarshal([]byte(line), &obj); err != nil {
- env.T.Errorf("Transcript line %d is not valid JSON: %v\nLine: %s", i+1, err, line)
- }
- }
-
- if validLines == 0 {
- env.T.Error("Transcript is empty (no valid JSON content)")
- }
- }
-
- // Validate expected content appears in transcript
- for _, expected := range expectedContent {
- if !strings.Contains(content, expected) {
- env.T.Errorf("Transcript should contain %q", expected)
- }
- }
-}
-
-// validateContentHash validates that content_hash.txt matches the SHA256 of the transcript.
-func (env *TestEnv) validateContentHash(checkpointID string) {
- env.T.Helper()
-
- // Read transcript
- transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName)
- transcript, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath)
- if !found {
- env.T.Fatalf("Transcript not found at %s", transcriptPath)
- }
-
- // Read content hash
- hashPath := SessionFilePath(checkpointID, "content_hash.txt")
- storedHash, found := env.ReadFileFromBranch(paths.MetadataBranchName, hashPath)
- if !found {
- env.T.Fatalf("Content hash not found at %s", hashPath)
- }
- storedHash = strings.TrimSpace(storedHash)
-
- // Calculate expected hash with sha256: prefix (matches format in committed.go)
- hash := sha256.Sum256([]byte(transcript))
- expectedHash := "sha256:" + hex.EncodeToString(hash[:])
-
- if storedHash != expectedHash {
- env.T.Errorf("Content hash mismatch:\n stored: %s\n expected: %s", storedHash, expectedHash)
- }
-}
-
-// validatePromptContent validates that prompt.txt contains the expected prompts.
-func (env *TestEnv) validatePromptContent(checkpointID string, expectedPrompts []string) {
- env.T.Helper()
-
- promptPath := SessionFilePath(checkpointID, paths.PromptFileName)
- content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath)
- if !found {
- env.T.Fatalf("Prompt file not found at %s", promptPath)
- }
-
- for _, expected := range expectedPrompts {
- if !strings.Contains(content, expected) {
- env.T.Errorf("Prompt file should contain %q\nContent: %s", expected, content)
- }
- }
-}
-
-// SetupBareRemote creates a bare git repository, adds it as "origin" remote to the
-// test repo, and pushes the current HEAD. Returns the bare repo path.
-// This mirrors the E2E helper in e2e/testutil/repo.go but adapted for TestEnv.
-func (env *TestEnv) SetupBareRemote() string {
- env.T.Helper()
- return env.SetupNamedBareRemote("origin")
-}
-
-// SetupNamedBareRemote creates a bare git repository with a custom remote name.
-// Returns the bare repo path. Use this for checkpoint_remote scenarios that need
-// multiple remotes.
-func (env *TestEnv) SetupNamedBareRemote(remoteName string) string {
- env.T.Helper()
-
- ctx := env.T.Context()
-
- bareDir := env.T.TempDir()
- if resolved, err := filepath.EvalSymlinks(bareDir); err == nil {
- bareDir = resolved
- }
-
- // Initialize bare repo
- cmd := exec.CommandContext(ctx, "git", "init", "--bare")
- cmd.Dir = bareDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("failed to init bare repo: %v\n%s", err, output)
- }
-
- // Add as remote
- cmd = exec.CommandContext(ctx, "git", "remote", "add", remoteName, bareDir)
- cmd.Dir = env.RepoDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("failed to add remote %s: %v\n%s", remoteName, err, output)
- }
-
- // Push HEAD to the remote
- cmd = exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", remoteName, "HEAD")
- cmd.Dir = env.RepoDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("failed to push to %s: %v\n%s", remoteName, err, output)
- }
-
- env.setGitConfigBaseline()
-
- return bareDir
-}
-
-// CloneFrom clones from a bare repo into a new temp directory and returns a new TestEnv
-// pointing at the clone. The clone has its own .trace directory initialized.
-// The clone checks out the same branch as the current env's HEAD.
-func (env *TestEnv) CloneFrom(bareDir string) *TestEnv {
- env.T.Helper()
-
- ctx := env.T.Context()
-
- cloneDir := env.T.TempDir()
- if resolved, err := filepath.EvalSymlinks(cloneDir); err == nil {
- cloneDir = resolved
- }
-
- // Get the current branch name to clone the right branch
- currentBranch := env.GetCurrentBranch()
-
- // Clone the bare repo, explicitly checking out the right branch.
- // Bare repos may have HEAD pointing to a non-existent default branch
- // when the original was on a feature branch.
- cloneArgs := []string{"clone"}
- if currentBranch != "" {
- cloneArgs = append(cloneArgs, "--branch", currentBranch)
- }
- cloneArgs = append(cloneArgs, bareDir, cloneDir)
- cmd := exec.CommandContext(ctx, "git", cloneArgs...)
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("failed to clone from %s: %v\n%s", bareDir, err, output)
- }
-
- // Configure git user (clone doesn't inherit local config from the bare repo)
- for _, kv := range [][2]string{
- {"user.name", "Test User"},
- {"user.email", "test@example.com"},
- {"commit.gpgsign", "false"},
- } {
- cmd = exec.CommandContext(ctx, "git", "config", kv[0], kv[1])
- cmd.Dir = cloneDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("failed to set git config %s: %v\n%s", kv[0], err, output)
- }
- }
-
- claudeProjectDir := env.T.TempDir()
- if resolved, err := filepath.EvalSymlinks(claudeProjectDir); err == nil {
- claudeProjectDir = resolved
- }
- geminiProjectDir := env.T.TempDir()
- if resolved, err := filepath.EvalSymlinks(geminiProjectDir); err == nil {
- geminiProjectDir = resolved
- }
- openCodeProjectDir := env.T.TempDir()
- if resolved, err := filepath.EvalSymlinks(openCodeProjectDir); err == nil {
- openCodeProjectDir = resolved
- }
-
- cloneEnv := &TestEnv{
- T: env.T,
- RepoDir: cloneDir,
- ClaudeProjectDir: claudeProjectDir,
- GeminiProjectDir: geminiProjectDir,
- OpenCodeProjectDir: openCodeProjectDir,
- }
-
- // Initialize Trace in the clone
- cloneEnv.InitTrace()
- cloneEnv.setGitConfigBaseline()
-
- return cloneEnv
-}
-
-// BranchExistsOnRemote checks if a branch exists on a bare remote by inspecting its refs.
-func (env *TestEnv) BranchExistsOnRemote(bareDir, branchName string) bool {
- env.T.Helper()
-
- cmd := exec.CommandContext(env.T.Context(), "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
- cmd.Dir = bareDir
- cmd.Env = testutil.GitIsolatedEnv()
- return cmd.Run() == nil
-}
-
-// PatchSettings merges extra keys into .trace/settings.json.
-func (env *TestEnv) PatchSettings(extra map[string]any) {
- env.T.Helper()
-
- settingsPath := filepath.Join(env.RepoDir, ".trace", paths.SettingsFileName)
- data, err := os.ReadFile(settingsPath) //nolint:gosec // G304: path is constructed from test env, not user input
- if err != nil {
- env.T.Fatalf("failed to read settings: %v", err)
- }
-
- var settings map[string]any
- if err := json.Unmarshal(data, &settings); err != nil {
- env.T.Fatalf("failed to parse settings: %v", err)
- }
-
- for k, v := range extra {
- settings[k] = v
- }
-
- out, err := json.MarshalIndent(settings, "", " ")
- if err != nil {
- env.T.Fatalf("failed to marshal settings: %v", err)
- }
- out = append(out, '\n')
-
- if err := os.WriteFile(settingsPath, out, 0o644); err != nil { //nolint:gosec // G306: consistent with other settings writes in testenv.go
- env.T.Fatalf("failed to write settings: %v", err)
- }
-}
-
-// GitPush pushes a branch to a remote. Fails the test on error.
-func (env *TestEnv) GitPush(remote, refSpec string) {
- env.T.Helper()
-
- cmd := exec.CommandContext(env.T.Context(), "git", "push", "--no-verify", remote, refSpec)
- cmd.Dir = env.RepoDir
- cmd.Env = testutil.GitIsolatedEnv()
- if output, err := cmd.CombinedOutput(); err != nil {
- env.T.Fatalf("git push %s %s failed: %v\n%s", remote, refSpec, err, output)
- }
-}
-
-// RunPrePush runs the pre-push hook via the CLI binary, consistent with how
-// other CLI invocations (GitCommitWithShadowHooks, RunCLI) use env.cliEnv().
-func (env *TestEnv) RunPrePush(remote string) {
- env.T.Helper()
- if err := env.RunPrePushWithError(remote); err != nil {
- env.T.Fatalf("PrePush failed: %v", err)
- }
-}
-
-// RunPrePushWithError runs the pre-push hook and returns any error instead of failing.
-func (env *TestEnv) RunPrePushWithError(remote string) error {
- env.T.Helper()
-
- cmd := exec.CommandContext(env.T.Context(), getTestBinary(), "hooks", "git", "pre-push", remote)
- cmd.Dir = env.RepoDir
- cmd.Env = env.cliEnv()
- cmd.Stdin = nil
-
- output, err := cmd.CombinedOutput()
- env.T.Logf("pre-push output: %s", output)
- if err != nil {
- return fmt.Errorf("pre-push hook failed: %w", err)
- }
- return nil
-}
-
-// FetchMetadataBranch fetches the trace/checkpoints/v1 branch from a remote URL.
-// Fails the test on error. Use this for clone-and-resume tests that need metadata.
-func (env *TestEnv) FetchMetadataBranch(remoteURL string) {
- env.T.Helper()
-
- branchName := paths.MetadataBranchName
- refSpec := "+refs/heads/" + branchName + ":refs/heads/" + branchName
- cmd := exec.CommandContext(env.T.Context(), "git", "fetch", "--no-tags", remoteURL, refSpec)
- cmd.Dir = env.RepoDir
- cmd.Env = testutil.GitIsolatedEnv()
-
- output, err := cmd.CombinedOutput()
- if err != nil {
- env.T.Fatalf("fetch metadata branch failed: %v\n%s", err, output)
- }
-}
-
-// GetBranchTipParentCount returns the number of parents for the tip commit of a branch.
-func (env *TestEnv) GetBranchTipParentCount(branchName string) int {
- env.T.Helper()
-
- repo, err := git.PlainOpen(env.RepoDir)
- if err != nil {
- env.T.Fatalf("failed to open git repo: %v", err)
- }
-
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- if err != nil {
- env.T.Fatalf("failed to get branch %s: %v", branchName, err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- env.T.Fatalf("failed to get commit for branch %s: %v", branchName, err)
- }
-
- return len(commit.ParentHashes)
-}
-
-func findModuleRoot() string {
- // Start from this source file's location and walk up to find go.mod
- _, thisFile, _, ok := runtime.Caller(0)
- if !ok {
- panic("failed to get current file path via runtime.Caller")
- }
- dir := filepath.Dir(thisFile)
-
- for {
- if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
- return dir
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- panic("could not find go.mod starting from " + thisFile)
- }
- dir = parent
- }
-}
diff --git a/cli/integration_test/testenv_2.go b/cli/integration_test/testenv_2.go
new file mode 100644
index 0000000..685f822
--- /dev/null
+++ b/cli/integration_test/testenv_2.go
@@ -0,0 +1,804 @@
+//go:build integration
+
+package integration
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/execx"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// RewindReset performs a reset rewind using the CLI.
+// This resets the branch to the specified commit (destructive).
+func (env *TestEnv) RewindReset(commitID string) error {
+ env.T.Helper()
+
+ // Run rewind --to --reset using the shared binary
+ cmd := exec.Command(getTestBinary(), "checkpoint", "rewind", "--to", commitID, "--reset")
+ cmd.Dir = env.RepoDir
+ cmd.Env = env.cliEnv()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return errors.New("rewind reset failed: " + string(output))
+ }
+
+ env.T.Logf("Rewind reset output: %s", output)
+ return nil
+}
+
+// BranchExists checks if a branch exists in the repository.
+func (env *TestEnv) BranchExists(branchName string) bool {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ return err == nil
+}
+
+// GetCommitMessage returns the commit message for the given commit hash.
+func (env *TestEnv) GetCommitMessage(hash string) string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ commitHash := plumbing.NewHash(hash)
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ env.T.Fatalf("failed to get commit %s: %v", hash, err)
+ }
+
+ return commit.Message
+}
+
+// FileExistsInBranch checks if a file exists in a specific branch's tree.
+func (env *TestEnv) FileExistsInBranch(branchName, filePath string) bool {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ // Get the branch reference
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ if err != nil {
+ // Try as a remote-style ref
+ ref, err = repo.Reference(plumbing.ReferenceName("refs/heads/"+branchName), true)
+ if err != nil {
+ return false
+ }
+ }
+
+ // Get the commit
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return false
+ }
+
+ // Get the tree
+ tree, err := commit.Tree()
+ if err != nil {
+ return false
+ }
+
+ // Check if file exists
+ _, err = tree.File(filePath)
+ return err == nil
+}
+
+// ReadFileFromBranch reads a file's content from a specific branch's tree.
+// Returns the content and true if found, empty string and false if not found.
+func (env *TestEnv) ReadFileFromBranch(branchName, filePath string) (string, bool) {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ // Get the branch reference
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ if err != nil {
+ // Try as a remote-style ref
+ ref, err = repo.Reference(plumbing.ReferenceName("refs/heads/"+branchName), true)
+ if err != nil {
+ return "", false
+ }
+ }
+
+ // Get the commit
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return "", false
+ }
+
+ // Get the tree
+ tree, err := commit.Tree()
+ if err != nil {
+ return "", false
+ }
+
+ // Get the file
+ file, err := tree.File(filePath)
+ if err != nil {
+ return "", false
+ }
+
+ // Get the content
+ content, err := file.Contents()
+ if err != nil {
+ return "", false
+ }
+
+ return content, true
+}
+
+// ReadFileFromRef reads a file's content from a specific ref's tree.
+// Unlike ReadFileFromBranch, this takes a full ref name (e.g., "refs/trace/checkpoints/v2/main")
+// and does not prepend "refs/heads/".
+// Returns the content and true if found, empty string and false if not found.
+func (env *TestEnv) ReadFileFromRef(refName, filePath string) (string, bool) {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ ref, err := repo.Reference(plumbing.ReferenceName(refName), true)
+ if err != nil {
+ return "", false
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return "", false
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return "", false
+ }
+
+ file, err := tree.File(filePath)
+ if err != nil {
+ return "", false
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ return "", false
+ }
+
+ return content, true
+}
+
+// AssertCheckpointContainsSession verifies that the checkpoint summary includes
+// a session with the given session ID by reading per-session metadata from the
+// metadata branch.
+func (env *TestEnv) AssertCheckpointContainsSession(t *testing.T, summary checkpoint.CheckpointSummary, sessionID string) {
+ t.Helper()
+ for _, s := range summary.Sessions {
+ if env.sessionMetadataMatchesID(s.Metadata, sessionID) {
+ return
+ }
+ }
+ t.Errorf("Checkpoint did not include session %q", sessionID)
+}
+
+// AssertCheckpointExcludesSession verifies that the checkpoint summary does NOT
+// include a session with the given session ID.
+func (env *TestEnv) AssertCheckpointExcludesSession(t *testing.T, summary checkpoint.CheckpointSummary, sessionID string) {
+ t.Helper()
+ for _, s := range summary.Sessions {
+ if env.sessionMetadataMatchesID(s.Metadata, sessionID) {
+ t.Errorf("Checkpoint incorrectly included session %q", sessionID)
+ return
+ }
+ }
+}
+
+// sessionMetadataMatchesID reads session metadata from the metadata branch and
+// checks if it belongs to the given session ID.
+func (env *TestEnv) sessionMetadataMatchesID(metadataPath, sessionID string) bool {
+ // Strip leading slash — git tree paths are relative
+ cleanPath := strings.TrimPrefix(metadataPath, "/")
+ content, found := env.ReadFileFromBranch(paths.MetadataBranchName, cleanPath)
+ if !found {
+ return false
+ }
+ var meta checkpoint.CommittedMetadata
+ if err := json.Unmarshal([]byte(content), &meta); err != nil {
+ return false
+ }
+ return meta.SessionID == sessionID
+}
+
+// RefExists checks if a ref exists in the repository.
+func (env *TestEnv) RefExists(refName string) bool {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ _, err = repo.Reference(plumbing.ReferenceName(refName), true)
+ return err == nil
+}
+
+// GetLatestCommitMessageOnBranch returns the commit message of the latest commit on the given branch.
+func (env *TestEnv) GetLatestCommitMessageOnBranch(branchName string) string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ // Get the branch reference
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ if err != nil {
+ env.T.Fatalf("failed to get branch %s reference: %v", branchName, err)
+ }
+
+ // Get the commit
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ env.T.Fatalf("failed to get commit object: %v", err)
+ }
+
+ return commit.Message
+}
+
+// GitCommitWithShadowHooks stages and commits files, simulating the prepare-commit-msg
+// and post-commit hooks as a human (with TTY). This is the default for tests.
+func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) {
+ env.T.Helper()
+ env.gitCommitWithShadowHooks(message, true, files...)
+}
+
+// GitCommitWithShadowHooksAsAgent is like GitCommitWithShadowHooks but simulates
+// an agent commit (no TTY). This triggers the fast path in PrepareCommitMsg that
+// skips content detection and interactive prompts for ACTIVE sessions.
+func (env *TestEnv) GitCommitWithShadowHooksAsAgent(message string, files ...string) {
+ env.T.Helper()
+ env.gitCommitWithShadowHooks(message, false, files...)
+}
+
+// prepareCommitMsgCmd builds the prepare-commit-msg hook command. When
+// simulateTTY is true, TRACE_TEST_TTY=1 forces interactive=true (an in-test
+// stand-in for a real terminal — Setsid can't synthesize a TTY). When false,
+// the child runs in a new session without a controlling terminal so its
+// /dev/tty probe fails and CanPromptInteractively() returns false.
+func (env *TestEnv) prepareCommitMsgCmd(simulateTTY bool, hookArgs ...string) *exec.Cmd {
+ args := append([]string{"hooks", "git", "prepare-commit-msg"}, hookArgs...)
+ var cmd *exec.Cmd
+ if simulateTTY {
+ cmd = exec.Command(getTestBinary(), args...)
+ cmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
+ } else {
+ cmd = execx.NonInteractive(context.Background(), getTestBinary(), args...)
+ cmd.Env = env.gitHookEnv()
+ }
+ cmd.Dir = env.RepoDir
+ return cmd
+}
+
+// gitCommitWithShadowHooks is the shared implementation for committing with shadow hooks.
+func (env *TestEnv) gitCommitWithShadowHooks(message string, simulateTTY bool, files ...string) {
+ env.T.Helper()
+
+ // Stage files using go-git
+ for _, file := range files {
+ env.GitAdd(file)
+ }
+
+ // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
+ msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
+ if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
+ env.T.Fatalf("failed to write commit message file: %v", err)
+ }
+
+ // Run prepare-commit-msg hook using the shared binary.
+ // Pass source="message" to match real `git commit -m` behavior.
+ prepCmd := env.prepareCommitMsgCmd(simulateTTY, msgFile, "message")
+ if output, err := prepCmd.CombinedOutput(); err != nil {
+ env.T.Logf("prepare-commit-msg output: %s", output)
+ // Don't fail - hook may silently succeed
+ }
+
+ // Read the modified message
+ modifiedMsg, err := os.ReadFile(msgFile)
+ if err != nil {
+ env.T.Fatalf("failed to read modified commit message: %v", err)
+ }
+
+ // Create the commit using go-git with the modified message
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ env.T.Fatalf("failed to get worktree: %v", err)
+ }
+
+ _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test User",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ })
+ if err != nil {
+ env.T.Fatalf("failed to commit: %v", err)
+ }
+
+ // Run post-commit hook using the shared binary
+ // This triggers condensation if the commit has an Trace-Checkpoint trailer
+ postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
+ postCmd.Dir = env.RepoDir
+ postCmd.Env = env.gitHookEnv()
+ if output, err := postCmd.CombinedOutput(); err != nil {
+ env.T.Logf("post-commit output: %s", output)
+ // Don't fail - hook may silently succeed
+ }
+}
+
+func (env *TestEnv) gitHookEnv(extra ...string) []string {
+ envVars := append(
+ testutil.GitIsolatedEnv(),
+ "TRACE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,
+ "TRACE_TEST_OPENCODE_MOCK_EXPORT=1",
+ )
+ return append(envVars, extra...)
+}
+
+// GitCommitAmendWithShadowHooks amends the last commit with shadow hooks.
+// This simulates `git commit --amend` with the prepare-commit-msg and post-commit hooks.
+// The prepare-commit-msg hook is called with "commit" source to indicate an amend.
+func (env *TestEnv) GitCommitAmendWithShadowHooks(message string, files ...string) {
+ env.T.Helper()
+
+ // Stage any additional files
+ for _, file := range files {
+ env.GitAdd(file)
+ }
+
+ // Write commit message to temp file
+ msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
+ if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
+ env.T.Fatalf("failed to write commit message file: %v", err)
+ }
+
+ // Run prepare-commit-msg hook with "commit" source (indicates amend).
+ // Set TRACE_TEST_TTY=1 to simulate human (amend is always a human operation).
+ prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "commit")
+ prepCmd.Dir = env.RepoDir
+ prepCmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
+ if output, err := prepCmd.CombinedOutput(); err != nil {
+ env.T.Logf("prepare-commit-msg (amend) output: %s", output)
+ }
+
+ // Read the modified message
+ modifiedMsg, err := os.ReadFile(msgFile)
+ if err != nil {
+ env.T.Fatalf("failed to read modified commit message: %v", err)
+ }
+
+ // Amend the commit using go-git
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ env.T.Fatalf("failed to get worktree: %v", err)
+ }
+
+ _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test User",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ Amend: true,
+ })
+ if err != nil {
+ env.T.Fatalf("failed to amend commit: %v", err)
+ }
+
+ // Run post-commit hook
+ postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
+ postCmd.Dir = env.RepoDir
+ postCmd.Env = env.gitHookEnv()
+ if output, err := postCmd.CombinedOutput(); err != nil {
+ env.T.Logf("post-commit (amend) output: %s", output)
+ }
+}
+
+// GitPostRewriteWithShadowHooks runs the git post-rewrite hook with the provided
+// old->new commit mappings. Each mapping is a pair of commit SHAs.
+func (env *TestEnv) GitPostRewriteWithShadowHooks(rewriteType string, mappings ...[2]string) {
+ env.T.Helper()
+
+ var input strings.Builder
+ for _, mapping := range mappings {
+ input.WriteString(mapping[0])
+ input.WriteByte(' ')
+ input.WriteString(mapping[1])
+ input.WriteByte('\n')
+ }
+
+ cmd := exec.Command(getTestBinary(), "hooks", "git", "post-rewrite", rewriteType)
+ cmd.Dir = env.RepoDir
+ cmd.Env = env.gitHookEnv()
+ cmd.Stdin = strings.NewReader(input.String())
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("post-rewrite hook failed: %v\nOutput: %s", err, output)
+ }
+}
+
+// GitCommitWithTrailerRemoved stages and commits files, simulating what happens when
+// a user removes the Trace-Checkpoint trailer during commit message editing.
+// This tests the opt-out behavior where removing the trailer skips condensation.
+func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) {
+ env.T.Helper()
+
+ // Stage files using go-git
+ for _, file := range files {
+ env.GitAdd(file)
+ }
+
+ // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
+ msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
+ if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
+ env.T.Fatalf("failed to write commit message file: %v", err)
+ }
+
+ // Run prepare-commit-msg hook using the shared binary.
+ // Set TRACE_TEST_TTY=1 to simulate human (this tests the editor flow where
+ // the user removes the trailer before committing).
+ prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile)
+ prepCmd.Dir = env.RepoDir
+ prepCmd.Env = env.gitHookEnv("TRACE_TEST_TTY=1")
+ if output, err := prepCmd.CombinedOutput(); err != nil {
+ env.T.Logf("prepare-commit-msg output: %s", output)
+ }
+
+ // Read the modified message (with trailer added by hook)
+ modifiedMsg, err := os.ReadFile(msgFile)
+ if err != nil {
+ env.T.Fatalf("failed to read modified commit message: %v", err)
+ }
+
+ // REMOVE the Trace-Checkpoint trailer (simulating user editing the message)
+ lines := strings.Split(string(modifiedMsg), "\n")
+ var cleanedLines []string
+ for _, line := range lines {
+ // Skip the trailer and the comments about it
+ if strings.HasPrefix(line, "Trace-Checkpoint:") {
+ continue
+ }
+ if strings.Contains(line, "Remove the Trace-Checkpoint trailer") {
+ continue
+ }
+ if strings.Contains(line, "trailer will be added to your next commit") {
+ continue
+ }
+ cleanedLines = append(cleanedLines, line)
+ }
+ cleanedMsg := strings.TrimRight(strings.Join(cleanedLines, "\n"), "\n") + "\n"
+
+ // Create the commit using go-git with the cleaned message (no trailer)
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ env.T.Fatalf("failed to get worktree: %v", err)
+ }
+
+ _, err = worktree.Commit(cleanedMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test User",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ })
+ if err != nil {
+ env.T.Fatalf("failed to commit: %v", err)
+ }
+
+ // Run post-commit hook - since trailer was removed, no condensation should happen
+ postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
+ postCmd.Dir = env.RepoDir
+ postCmd.Env = env.gitHookEnv()
+ if output, err := postCmd.CombinedOutput(); err != nil {
+ env.T.Logf("post-commit output: %s", output)
+ }
+}
+
+// GitRm stages file deletions using git rm.
+func (env *TestEnv) GitRm(paths ...string) {
+ env.T.Helper()
+
+ args := append([]string{"rm", "--"}, paths...)
+ cmd := exec.Command("git", args...)
+ cmd.Dir = env.RepoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("git rm failed: %v\nOutput: %s", err, output)
+ }
+}
+
+// GitCommitStagedWithShadowHooks commits whatever is already staged (without adding files first),
+// running the prepare-commit-msg and post-commit hooks like a real workflow.
+// Use this after GitRm or when files are already staged.
+func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) {
+ env.T.Helper()
+ env.gitCommitStagedWithShadowHooks(message, true)
+}
+
+// gitCommitStagedWithShadowHooks is the shared implementation for committing staged changes with hooks.
+func (env *TestEnv) gitCommitStagedWithShadowHooks(message string, simulateTTY bool) {
+ env.T.Helper()
+
+ // Create a temp file for the commit message (prepare-commit-msg hook modifies this)
+ msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG")
+ if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil {
+ env.T.Fatalf("failed to write commit message file: %v", err)
+ }
+
+ // Run prepare-commit-msg hook using the shared binary.
+ prepCmd := env.prepareCommitMsgCmd(simulateTTY, msgFile, "message")
+ if output, err := prepCmd.CombinedOutput(); err != nil {
+ env.T.Logf("prepare-commit-msg output: %s", output)
+ }
+
+ // Read the modified message
+ modifiedMsg, err := os.ReadFile(msgFile)
+ if err != nil {
+ env.T.Fatalf("failed to read modified commit message: %v", err)
+ }
+
+ // Create the commit using go-git with the modified message
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ env.T.Fatalf("failed to get worktree: %v", err)
+ }
+
+ _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test User",
+ Email: "test@example.com",
+ When: time.Now(),
+ },
+ })
+ if err != nil {
+ env.T.Fatalf("failed to commit: %v", err)
+ }
+
+ // Run post-commit hook
+ postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit")
+ postCmd.Dir = env.RepoDir
+ postCmd.Env = env.gitHookEnv()
+ if output, err := postCmd.CombinedOutput(); err != nil {
+ env.T.Logf("post-commit output: %s", output)
+ }
+}
+
+// ListBranchesWithPrefix returns all branches that start with the given prefix.
+func (env *TestEnv) ListBranchesWithPrefix(prefix string) []string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ refs, err := repo.References()
+ if err != nil {
+ env.T.Fatalf("failed to get references: %v", err)
+ }
+
+ var branches []string
+ _ = refs.ForEach(func(ref *plumbing.Reference) error {
+ name := ref.Name().Short()
+ if len(name) >= len(prefix) && name[:len(prefix)] == prefix {
+ branches = append(branches, name)
+ }
+ return nil
+ })
+
+ return branches
+}
+
+// GetLatestCheckpointID returns the most recent checkpoint ID from the trace/checkpoints/v1 branch.
+// This is used by tests that previously extracted the checkpoint ID from commit message trailers.
+// Now that active branch commits are clean (no trailers), we get the ID from the sessions branch.
+// Fatals if the checkpoint ID cannot be found, with detailed context about what was found.
+func (env *TestEnv) GetLatestCheckpointID() string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ // Get the trace/checkpoints/v1 branch
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ env.T.Fatalf("failed to get %s branch: %v", paths.MetadataBranchName, err)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ env.T.Fatalf("failed to get commit: %v", err)
+ }
+
+ // Extract checkpoint ID from commit message
+ // Format: "Checkpoint: <12-hex-char-id>\n\nSession: ...\nStrategy: ..."
+ for _, line := range strings.Split(commit.Message, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "Checkpoint: ") {
+ return strings.TrimPrefix(line, "Checkpoint: ")
+ }
+ }
+
+ env.T.Fatalf("could not find checkpoint ID in %s branch commit message:\n%s",
+ paths.MetadataBranchName, commit.Message)
+ return ""
+}
+
+// TryGetLatestCheckpointID returns the most recent checkpoint ID from the trace/checkpoints/v1 branch.
+// Returns empty string if the branch doesn't exist or has no checkpoint commits yet.
+// Use this when you need to check if a checkpoint exists without failing the test.
+func (env *TestEnv) TryGetLatestCheckpointID() string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ return ""
+ }
+
+ // Get the trace/checkpoints/v1 branch
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ return ""
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return ""
+ }
+
+ // Extract checkpoint ID from commit message
+ // Format: "Checkpoint: <12-hex-char-id>\n\nSession: ...\nStrategy: ..."
+ for _, line := range strings.Split(commit.Message, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "Checkpoint: ") {
+ return strings.TrimPrefix(line, "Checkpoint: ")
+ }
+ }
+
+ return ""
+}
+
+// GetLatestCondensationID is an alias for GetLatestCheckpointID for backwards compatibility.
+func (env *TestEnv) GetLatestCondensationID() string {
+ return env.GetLatestCheckpointID()
+}
+
+// GetCheckpointIDFromCommitMessage extracts the Trace-Checkpoint trailer from a commit message.
+// Returns empty string if no trailer found.
+func (env *TestEnv) GetCheckpointIDFromCommitMessage(commitSHA string) string {
+ env.T.Helper()
+
+ msg := env.GetCommitMessage(commitSHA)
+ cpID, found := trailers.ParseCheckpoint(msg)
+ if !found {
+ return ""
+ }
+ return cpID.String()
+}
+
+// GetLatestCheckpointIDFromHistory walks backwards from HEAD on the active branch
+// and returns the checkpoint ID from the first commit that has an Trace-Checkpoint trailer.
+// This verifies that condensation actually happened (commit has trailer) without relying
+// on timestamp-based matching.
+func (env *TestEnv) GetLatestCheckpointIDFromHistory() string {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ head, err := repo.Head()
+ if err != nil {
+ env.T.Fatalf("failed to get HEAD: %v", err)
+ }
+
+ commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()})
+ if err != nil {
+ env.T.Fatalf("failed to iterate commits: %v", err)
+ }
+
+ var checkpointID string
+ //nolint:errcheck // ForEach callback handles errors
+ commitIter.ForEach(func(c *object.Commit) error {
+ if cpID, found := trailers.ParseCheckpoint(c.Message); found {
+ checkpointID = cpID.String()
+ return errors.New("stop iteration") // Found it, stop
+ }
+ return nil
+ })
+
+ if checkpointID == "" {
+ env.T.Fatalf("no commit with Trace-Checkpoint trailer found in history")
+ }
+
+ return checkpointID
+}
+
+// ShardedCheckpointPath returns the sharded path for a checkpoint ID.
+// Format: /
+// Delegates to id.CheckpointID.Path() for consistency.
+func ShardedCheckpointPath(checkpointID string) string {
+ return id.CheckpointID(checkpointID).Path()
+}
+
+// SessionFilePath returns the path to a session file within a checkpoint.
+// Session files are stored in numbered subdirectories using 0-based indexing (e.g., 0/full.jsonl).
+// This function constructs the path for the first (default) session.
+func SessionFilePath(checkpointID string, fileName string) string {
+ return id.CheckpointID(checkpointID).Path() + "/0/" + fileName
+}
+
+// CheckpointSummaryPath returns the path to the root metadata.json (CheckpointSummary) for a checkpoint.
+func CheckpointSummaryPath(checkpointID string) string {
+ return id.CheckpointID(checkpointID).Path() + "/" + paths.MetadataFileName
+}
+
+// SessionMetadataPath returns the path to the session-level metadata.json for a checkpoint.
+func SessionMetadataPath(checkpointID string) string {
+ return SessionFilePath(checkpointID, paths.MetadataFileName)
+}
diff --git a/cli/integration_test/testenv_3.go b/cli/integration_test/testenv_3.go
new file mode 100644
index 0000000..6c0ae15
--- /dev/null
+++ b/cli/integration_test/testenv_3.go
@@ -0,0 +1,527 @@
+//go:build integration
+
+package integration
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+// CheckpointValidation contains expected values for checkpoint validation.
+type CheckpointValidation struct {
+ // CheckpointID is the expected checkpoint ID
+ CheckpointID string
+
+ // SessionID is the expected session ID
+ SessionID string
+
+ // Strategy is the expected strategy name
+ Strategy string
+
+ // FilesTouched are the expected files in files_touched
+ FilesTouched []string
+
+ // ExpectedPrompts are strings that should appear in prompt.txt
+ ExpectedPrompts []string
+
+ // ExpectedTranscriptContent are strings that should appear in full.jsonl
+ ExpectedTranscriptContent []string
+
+ // CheckpointsCount is the expected checkpoint count (0 means don't validate)
+ CheckpointsCount int
+}
+
+// ValidateCheckpoint performs comprehensive validation of a checkpoint on the metadata branch.
+// It validates:
+// - Root metadata.json (CheckpointSummary) structure and expected fields
+// - Session metadata.json (CommittedMetadata) structure and expected fields
+// - Transcript file (full.jsonl) is valid JSONL and contains expected content
+// - Content hash file (content_hash.txt) matches SHA256 of transcript
+// - Prompt file (prompt.txt) contains expected prompts
+func (env *TestEnv) ValidateCheckpoint(v CheckpointValidation) {
+ env.T.Helper()
+
+ // Validate root metadata.json (CheckpointSummary)
+ env.validateCheckpointSummary(v)
+
+ // Validate session metadata.json (CommittedMetadata)
+ env.validateSessionMetadata(v)
+
+ // Validate transcript is valid JSONL
+ env.validateTranscriptJSONL(v.CheckpointID, v.ExpectedTranscriptContent)
+
+ // Validate content hash matches transcript
+ env.validateContentHash(v.CheckpointID)
+
+ // Validate prompt.txt contains expected prompts
+ if len(v.ExpectedPrompts) > 0 {
+ env.validatePromptContent(v.CheckpointID, v.ExpectedPrompts)
+ }
+}
+
+// validateCheckpointSummary validates the root metadata.json (CheckpointSummary).
+func (env *TestEnv) validateCheckpointSummary(v CheckpointValidation) {
+ env.T.Helper()
+
+ summaryPath := CheckpointSummaryPath(v.CheckpointID)
+ content, found := env.ReadFileFromBranch(paths.MetadataBranchName, summaryPath)
+ if !found {
+ env.T.Fatalf("CheckpointSummary not found at %s", summaryPath)
+ }
+
+ var summary checkpoint.CheckpointSummary
+ if err := json.Unmarshal([]byte(content), &summary); err != nil {
+ env.T.Fatalf("Failed to parse CheckpointSummary: %v\nContent: %s", err, content)
+ }
+
+ // Validate checkpoint_id
+ if summary.CheckpointID.String() != v.CheckpointID {
+ env.T.Errorf("CheckpointSummary.CheckpointID = %q, want %q", summary.CheckpointID, v.CheckpointID)
+ }
+
+ // Validate strategy
+ if v.Strategy != "" && summary.Strategy != v.Strategy {
+ env.T.Errorf("CheckpointSummary.Strategy = %q, want %q", summary.Strategy, v.Strategy)
+ }
+
+ // Validate sessions array is populated
+ if len(summary.Sessions) == 0 {
+ env.T.Error("CheckpointSummary.Sessions should have at least one entry")
+ }
+
+ // Validate files_touched
+ if len(v.FilesTouched) > 0 {
+ touchedSet := make(map[string]bool)
+ for _, f := range summary.FilesTouched {
+ touchedSet[f] = true
+ }
+ for _, expected := range v.FilesTouched {
+ if !touchedSet[expected] {
+ env.T.Errorf("CheckpointSummary.FilesTouched missing %q, got %v", expected, summary.FilesTouched)
+ }
+ }
+ }
+
+ // Validate checkpoints_count
+ if v.CheckpointsCount > 0 && summary.CheckpointsCount != v.CheckpointsCount {
+ env.T.Errorf("CheckpointSummary.CheckpointsCount = %d, want %d", summary.CheckpointsCount, v.CheckpointsCount)
+ }
+}
+
+// validateSessionMetadata validates the session-level metadata.json (CommittedMetadata).
+func (env *TestEnv) validateSessionMetadata(v CheckpointValidation) {
+ env.T.Helper()
+
+ metadataPath := SessionMetadataPath(v.CheckpointID)
+ content, found := env.ReadFileFromBranch(paths.MetadataBranchName, metadataPath)
+ if !found {
+ env.T.Fatalf("Session metadata not found at %s", metadataPath)
+ }
+
+ var metadata checkpoint.CommittedMetadata
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ env.T.Fatalf("Failed to parse CommittedMetadata: %v\nContent: %s", err, content)
+ }
+
+ // Validate checkpoint_id
+ if metadata.CheckpointID.String() != v.CheckpointID {
+ env.T.Errorf("CommittedMetadata.CheckpointID = %q, want %q", metadata.CheckpointID, v.CheckpointID)
+ }
+
+ // Validate session_id
+ if v.SessionID != "" && metadata.SessionID != v.SessionID {
+ env.T.Errorf("CommittedMetadata.SessionID = %q, want %q", metadata.SessionID, v.SessionID)
+ }
+
+ // Validate strategy
+ if v.Strategy != "" && metadata.Strategy != v.Strategy {
+ env.T.Errorf("CommittedMetadata.Strategy = %q, want %q", metadata.Strategy, v.Strategy)
+ }
+
+ // Validate created_at is not zero
+ if metadata.CreatedAt.IsZero() {
+ env.T.Error("CommittedMetadata.CreatedAt should not be zero")
+ }
+
+ // Validate files_touched
+ if len(v.FilesTouched) > 0 {
+ touchedSet := make(map[string]bool)
+ for _, f := range metadata.FilesTouched {
+ touchedSet[f] = true
+ }
+ for _, expected := range v.FilesTouched {
+ if !touchedSet[expected] {
+ env.T.Errorf("CommittedMetadata.FilesTouched missing %q, got %v", expected, metadata.FilesTouched)
+ }
+ }
+ }
+
+ // Validate checkpoints_count
+ if v.CheckpointsCount > 0 && metadata.CheckpointsCount != v.CheckpointsCount {
+ env.T.Errorf("CommittedMetadata.CheckpointsCount = %d, want %d", metadata.CheckpointsCount, v.CheckpointsCount)
+ }
+}
+
+// validateTranscriptJSONL validates that full.jsonl exists and is valid JSON or JSONL.
+// It supports both:
+// - JSON format (single document, used by OpenCode and Gemini CLI)
+// - JSONL format (one JSON object per line, used by Claude Code)
+func (env *TestEnv) validateTranscriptJSONL(checkpointID string, expectedContent []string) {
+ env.T.Helper()
+
+ transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName)
+ content, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath)
+ if !found {
+ env.T.Fatalf("Transcript not found at %s", transcriptPath)
+ }
+
+ // First try to parse as a single JSON document (OpenCode/Gemini format)
+ var jsonDoc any
+ if err := json.Unmarshal([]byte(content), &jsonDoc); err == nil {
+ // Valid JSON document - validation passed
+ } else {
+ // Fall back to JSONL validation (Claude Code format)
+ lines := strings.Split(content, "\n")
+ validLines := 0
+ for i, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ validLines++
+ var obj map[string]any
+ if err := json.Unmarshal([]byte(line), &obj); err != nil {
+ env.T.Errorf("Transcript line %d is not valid JSON: %v\nLine: %s", i+1, err, line)
+ }
+ }
+
+ if validLines == 0 {
+ env.T.Error("Transcript is empty (no valid JSON content)")
+ }
+ }
+
+ // Validate expected content appears in transcript
+ for _, expected := range expectedContent {
+ if !strings.Contains(content, expected) {
+ env.T.Errorf("Transcript should contain %q", expected)
+ }
+ }
+}
+
+// validateContentHash validates that content_hash.txt matches the SHA256 of the transcript.
+func (env *TestEnv) validateContentHash(checkpointID string) {
+ env.T.Helper()
+
+ // Read transcript
+ transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName)
+ transcript, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath)
+ if !found {
+ env.T.Fatalf("Transcript not found at %s", transcriptPath)
+ }
+
+ // Read content hash
+ hashPath := SessionFilePath(checkpointID, "content_hash.txt")
+ storedHash, found := env.ReadFileFromBranch(paths.MetadataBranchName, hashPath)
+ if !found {
+ env.T.Fatalf("Content hash not found at %s", hashPath)
+ }
+ storedHash = strings.TrimSpace(storedHash)
+
+ // Calculate expected hash with sha256: prefix (matches format in committed.go)
+ hash := sha256.Sum256([]byte(transcript))
+ expectedHash := "sha256:" + hex.EncodeToString(hash[:])
+
+ if storedHash != expectedHash {
+ env.T.Errorf("Content hash mismatch:\n stored: %s\n expected: %s", storedHash, expectedHash)
+ }
+}
+
+// validatePromptContent validates that prompt.txt contains the expected prompts.
+func (env *TestEnv) validatePromptContent(checkpointID string, expectedPrompts []string) {
+ env.T.Helper()
+
+ promptPath := SessionFilePath(checkpointID, paths.PromptFileName)
+ content, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath)
+ if !found {
+ env.T.Fatalf("Prompt file not found at %s", promptPath)
+ }
+
+ for _, expected := range expectedPrompts {
+ if !strings.Contains(content, expected) {
+ env.T.Errorf("Prompt file should contain %q\nContent: %s", expected, content)
+ }
+ }
+}
+
+// SetupBareRemote creates a bare git repository, adds it as "origin" remote to the
+// test repo, and pushes the current HEAD. Returns the bare repo path.
+// This mirrors the E2E helper in e2e/testutil/repo.go but adapted for TestEnv.
+func (env *TestEnv) SetupBareRemote() string {
+ env.T.Helper()
+ return env.SetupNamedBareRemote("origin")
+}
+
+// SetupNamedBareRemote creates a bare git repository with a custom remote name.
+// Returns the bare repo path. Use this for checkpoint_remote scenarios that need
+// multiple remotes.
+func (env *TestEnv) SetupNamedBareRemote(remoteName string) string {
+ env.T.Helper()
+
+ ctx := env.T.Context()
+
+ bareDir := env.T.TempDir()
+ if resolved, err := filepath.EvalSymlinks(bareDir); err == nil {
+ bareDir = resolved
+ }
+
+ // Initialize bare repo
+ cmd := exec.CommandContext(ctx, "git", "init", "--bare")
+ cmd.Dir = bareDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("failed to init bare repo: %v\n%s", err, output)
+ }
+
+ // Add as remote
+ cmd = exec.CommandContext(ctx, "git", "remote", "add", remoteName, bareDir)
+ cmd.Dir = env.RepoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("failed to add remote %s: %v\n%s", remoteName, err, output)
+ }
+
+ // Push HEAD to the remote
+ cmd = exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", remoteName, "HEAD")
+ cmd.Dir = env.RepoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("failed to push to %s: %v\n%s", remoteName, err, output)
+ }
+
+ env.setGitConfigBaseline()
+
+ return bareDir
+}
+
+// CloneFrom clones from a bare repo into a new temp directory and returns a new TestEnv
+// pointing at the clone. The clone has its own .trace directory initialized.
+// The clone checks out the same branch as the current env's HEAD.
+func (env *TestEnv) CloneFrom(bareDir string) *TestEnv {
+ env.T.Helper()
+
+ ctx := env.T.Context()
+
+ cloneDir := env.T.TempDir()
+ if resolved, err := filepath.EvalSymlinks(cloneDir); err == nil {
+ cloneDir = resolved
+ }
+
+ // Get the current branch name to clone the right branch
+ currentBranch := env.GetCurrentBranch()
+
+ // Clone the bare repo, explicitly checking out the right branch.
+ // Bare repos may have HEAD pointing to a non-existent default branch
+ // when the original was on a feature branch.
+ cloneArgs := []string{"clone"}
+ if currentBranch != "" {
+ cloneArgs = append(cloneArgs, "--branch", currentBranch)
+ }
+ cloneArgs = append(cloneArgs, bareDir, cloneDir)
+ cmd := exec.CommandContext(ctx, "git", cloneArgs...)
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("failed to clone from %s: %v\n%s", bareDir, err, output)
+ }
+
+ // Configure git user (clone doesn't inherit local config from the bare repo)
+ for _, kv := range [][2]string{
+ {"user.name", "Test User"},
+ {"user.email", "test@example.com"},
+ {"commit.gpgsign", "false"},
+ } {
+ cmd = exec.CommandContext(ctx, "git", "config", kv[0], kv[1])
+ cmd.Dir = cloneDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("failed to set git config %s: %v\n%s", kv[0], err, output)
+ }
+ }
+
+ claudeProjectDir := env.T.TempDir()
+ if resolved, err := filepath.EvalSymlinks(claudeProjectDir); err == nil {
+ claudeProjectDir = resolved
+ }
+ geminiProjectDir := env.T.TempDir()
+ if resolved, err := filepath.EvalSymlinks(geminiProjectDir); err == nil {
+ geminiProjectDir = resolved
+ }
+ openCodeProjectDir := env.T.TempDir()
+ if resolved, err := filepath.EvalSymlinks(openCodeProjectDir); err == nil {
+ openCodeProjectDir = resolved
+ }
+
+ cloneEnv := &TestEnv{
+ T: env.T,
+ RepoDir: cloneDir,
+ ClaudeProjectDir: claudeProjectDir,
+ GeminiProjectDir: geminiProjectDir,
+ OpenCodeProjectDir: openCodeProjectDir,
+ }
+
+ // Initialize Trace in the clone
+ cloneEnv.InitTrace()
+ cloneEnv.setGitConfigBaseline()
+
+ return cloneEnv
+}
+
+// BranchExistsOnRemote checks if a branch exists on a bare remote by inspecting its refs.
+func (env *TestEnv) BranchExistsOnRemote(bareDir, branchName string) bool {
+ env.T.Helper()
+
+ cmd := exec.CommandContext(env.T.Context(), "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
+ cmd.Dir = bareDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ return cmd.Run() == nil
+}
+
+// PatchSettings merges extra keys into .trace/settings.json.
+func (env *TestEnv) PatchSettings(extra map[string]any) {
+ env.T.Helper()
+
+ settingsPath := filepath.Join(env.RepoDir, ".trace", paths.SettingsFileName)
+ data, err := os.ReadFile(settingsPath) //nolint:gosec // G304: path is constructed from test env, not user input
+ if err != nil {
+ env.T.Fatalf("failed to read settings: %v", err)
+ }
+
+ var settings map[string]any
+ if err := json.Unmarshal(data, &settings); err != nil {
+ env.T.Fatalf("failed to parse settings: %v", err)
+ }
+
+ for k, v := range extra {
+ settings[k] = v
+ }
+
+ out, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ env.T.Fatalf("failed to marshal settings: %v", err)
+ }
+ out = append(out, '\n')
+
+ if err := os.WriteFile(settingsPath, out, 0o644); err != nil { //nolint:gosec // G306: consistent with other settings writes in testenv.go
+ env.T.Fatalf("failed to write settings: %v", err)
+ }
+}
+
+// GitPush pushes a branch to a remote. Fails the test on error.
+func (env *TestEnv) GitPush(remote, refSpec string) {
+ env.T.Helper()
+
+ cmd := exec.CommandContext(env.T.Context(), "git", "push", "--no-verify", remote, refSpec)
+ cmd.Dir = env.RepoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if output, err := cmd.CombinedOutput(); err != nil {
+ env.T.Fatalf("git push %s %s failed: %v\n%s", remote, refSpec, err, output)
+ }
+}
+
+// RunPrePush runs the pre-push hook via the CLI binary, consistent with how
+// other CLI invocations (GitCommitWithShadowHooks, RunCLI) use env.cliEnv().
+func (env *TestEnv) RunPrePush(remote string) {
+ env.T.Helper()
+ if err := env.RunPrePushWithError(remote); err != nil {
+ env.T.Fatalf("PrePush failed: %v", err)
+ }
+}
+
+// RunPrePushWithError runs the pre-push hook and returns any error instead of failing.
+func (env *TestEnv) RunPrePushWithError(remote string) error {
+ env.T.Helper()
+
+ cmd := exec.CommandContext(env.T.Context(), getTestBinary(), "hooks", "git", "pre-push", remote)
+ cmd.Dir = env.RepoDir
+ cmd.Env = env.cliEnv()
+ cmd.Stdin = nil
+
+ output, err := cmd.CombinedOutput()
+ env.T.Logf("pre-push output: %s", output)
+ if err != nil {
+ return fmt.Errorf("pre-push hook failed: %w", err)
+ }
+ return nil
+}
+
+// FetchMetadataBranch fetches the trace/checkpoints/v1 branch from a remote URL.
+// Fails the test on error. Use this for clone-and-resume tests that need metadata.
+func (env *TestEnv) FetchMetadataBranch(remoteURL string) {
+ env.T.Helper()
+
+ branchName := paths.MetadataBranchName
+ refSpec := "+refs/heads/" + branchName + ":refs/heads/" + branchName
+ cmd := exec.CommandContext(env.T.Context(), "git", "fetch", "--no-tags", remoteURL, refSpec)
+ cmd.Dir = env.RepoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ env.T.Fatalf("fetch metadata branch failed: %v\n%s", err, output)
+ }
+}
+
+// GetBranchTipParentCount returns the number of parents for the tip commit of a branch.
+func (env *TestEnv) GetBranchTipParentCount(branchName string) int {
+ env.T.Helper()
+
+ repo, err := git.PlainOpen(env.RepoDir)
+ if err != nil {
+ env.T.Fatalf("failed to open git repo: %v", err)
+ }
+
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ if err != nil {
+ env.T.Fatalf("failed to get branch %s: %v", branchName, err)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ env.T.Fatalf("failed to get commit for branch %s: %v", branchName, err)
+ }
+
+ return len(commit.ParentHashes)
+}
+
+func findModuleRoot() string {
+ // Start from this source file's location and walk up to find go.mod
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ panic("failed to get current file path via runtime.Caller")
+ }
+ dir := filepath.Dir(thisFile)
+
+ for {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ panic("could not find go.mod starting from " + thisFile)
+ }
+ dir = parent
+ }
+}
diff --git a/cli/investigate/cmd.go b/cli/investigate/cmd.go
index e103c2f..23fa932 100644
--- a/cli/investigate/cmd.go
+++ b/cli/investigate/cmd.go
@@ -4,10 +4,8 @@ import (
"context"
"errors"
"fmt"
- "io"
"log/slog"
"os"
- "path/filepath"
"strings"
"time"
@@ -15,11 +13,8 @@ import (
"github.com/GrayCodeAI/trace/cli/agent/spawn"
"github.com/GrayCodeAI/trace/cli/agent/types"
- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/gitexec"
"github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
- "github.com/GrayCodeAI/trace/cli/mdrender"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/settings"
@@ -816,342 +811,3 @@ func resolveRunConfig(cfg *settings.InvestigateConfig, f runFlags) (agents []str
}
return agents, maxTurns, quorum, nil
}
-
-// parseAgentsCSV splits a comma-separated agent list, trimming whitespace
-// and dropping empty entries.
-func parseAgentsCSV(csv string) []string {
- parts := strings.Split(csv, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- if v := strings.TrimSpace(p); v != "" {
- out = append(out, v)
- }
- }
- return out
-}
-
-// verifyAgentsLaunchable confirms each agent has a non-nil Spawner AND has
-// hooks installed in the current repo.
-func verifyAgentsLaunchable(ctx context.Context, agents []string, deps Deps) error {
- if deps.SpawnerFor == nil {
- return errors.New("investigate: SpawnerFor not wired")
- }
- if deps.GetAgentsWithHooksInstalled == nil {
- return errors.New("investigate: GetAgentsWithHooksInstalled not wired")
- }
- installed := deps.GetAgentsWithHooksInstalled(ctx)
- installedSet := make(map[string]struct{}, len(installed))
- for _, n := range installed {
- installedSet[string(n)] = struct{}{}
- }
- for _, name := range agents {
- if deps.SpawnerFor(name) == nil {
- return fmt.Errorf("agent %q is not launchable (spawner missing)", name)
- }
- if _, ok := installedSet[name]; !ok {
- return fmt.Errorf("agent %q is not launchable (run `entire configure --agent %s` first)", name, name)
- }
- }
- return nil
-}
-
-// resolveTopicAndSeed turns the user's input args into a topic + (seed
-// doc path | issue link seed bytes + topic). pickerPrompt is the
-// "Investigation prompt" collected from the spawn-time multipicker; it
-// becomes the topic only when no seed-doc / --issue-link was supplied.
-// Exactly one of seedDoc / issueSeed / topic-only is set on return.
-func resolveTopicAndSeed(ctx context.Context, args []string, f runFlags, pickerPrompt string) (topic, seedDoc string, issueSeed []byte, issueTopic string, err error) {
- switch {
- case len(args) == 1:
- seedDoc = args[0]
- body, readErr := os.ReadFile(seedDoc) //nolint:gosec // path is user-supplied positional arg
- if readErr != nil {
- return "", "", nil, "", fmt.Errorf("read seed doc %s: %w", seedDoc, readErr)
- }
- topic = DeriveTopicFromSeed(body, seedDoc)
- return topic, seedDoc, nil, "", nil
- case strings.TrimSpace(f.issueLink) != "":
- res, resErr := ResolveIssueLink(ctx, f.issueLink)
- if resErr != nil {
- return "", "", nil, "", resErr
- }
- return res.Topic, "", res.SeedDoc, res.Topic, nil
- case strings.TrimSpace(pickerPrompt) != "":
- topic = strings.TrimSpace(pickerPrompt)
- return topic, "", nil, "", nil
- default:
- return "", "", nil, "", errors.New("missing investigation input: pass [seed-doc] or --issue-link, or enter an investigation prompt in the picker")
- }
-}
-
-// topicForBootstrap returns the topic value to embed in the bootstrap
-// scaffold. The seed-doc path takes precedence (Bootstrap re-derives from
-// the seed body), and the issue-link path uses IssueLinkTopic; only the
-// topic-only path puts the resolved topic into BootstrapInput.Topic.
-func topicForBootstrap(topic, seedDoc string, issueSeed []byte) string {
- if seedDoc != "" || len(issueSeed) > 0 {
- return ""
- }
- return topic
-}
-
-// resolveDocPaths returns the absolute findings path for a run. The
-// findings doc lives alongside state.json in the per-run directory under
-// the git common dir:
-//
-// /trace-investigations//findings.md
-// /trace-investigations//state.json
-//
-// Putting the per-run artefacts under the git common dir (rather than the
-// worktree's .trace/investigations/) keeps the worktree's working tree
-// clean — investigation findings are session-scoped scratch space, not
-// part of the user's source tree.
-func resolveDocPaths(commonDir, runID string) string {
- return filepath.Join(commonDir, InvestigationsDirName, runID, "findings.md")
-}
-
-// executeLoopAndCapture runs the loop and returns the LoopResult so the
-// caller can use it to compose a post-run manifest / footer.
-func executeLoopAndCapture(ctx context.Context, cmd *cobra.Command, in LoopInput, deps Deps) (LoopResult, error) {
- stateStore, err := NewStateStore(ctx)
- if err != nil {
- return LoopResult{}, fmt.Errorf("open run state store: %w", err)
- }
-
- out := cmd.OutOrStdout()
- progress, tuiSink, runCtx, cancelTUI := buildProgressSink(ctx, in, out)
- // Defers run LIFO. Register Wait first so cancelTUI fires BEFORE Wait
- // — Wait blocks on the Bubble Tea program exiting, and the ctx-watcher
- // in Start() needs ctx cancelled to push tea.Quit when no RunFinished
- // arrives (early loop return, validation error, etc.).
- if tuiSink != nil {
- tuiSink.Start(runCtx)
- defer tuiSink.Wait()
- }
- if cancelTUI != nil {
- defer cancelTUI()
- }
-
- ldeps := LoopDeps{
- SpawnerFor: deps.SpawnerFor,
- States: stateStore,
- Progress: progress,
- }
-
- runner := deps.LoopRun
- if runner == nil {
- runner = RunInvestigateLoop
- }
- result, runErr := runner(runCtx, in, ldeps)
- if runErr != nil {
- return result, fmt.Errorf("investigate loop: %w", runErr)
- }
- return result, nil
-}
-
-// buildProgressSink chooses between the Bubble Tea TUI and the plain-text
-// fallback based on terminal capability. In TTY mode ctx is wrapped in a
-// cancellable child so the in-TUI Ctrl+C handler can stop the run via the
-// same cancel function the cobra root would use on SIGINT. In non-TTY mode
-// the caller's ctx is returned unchanged and cancelTUI is nil.
-func buildProgressSink(ctx context.Context, in LoopInput, out io.Writer) (ProgressSink, *tuiProgressSink, context.Context, context.CancelFunc) { //nolint:ireturn // returns interface by design
- if !interactive.IsTerminalWriter(out) || !interactive.CanPromptInteractively() {
- return newTextProgressSink(out), nil, ctx, nil
- }
- runCtx, cancel := context.WithCancel(ctx)
- maxTurns := in.MaxTurns
- if maxTurns == 0 {
- maxTurns = defaultMaxTurns
- }
- quorum := in.Quorum
- if quorum == 0 {
- quorum = len(in.Agents)
- }
- sink := newTUIProgressSink(in.Topic, in.RunID, in.Agents, maxTurns, quorum, cancel, out)
- return sink, sink, runCtx, cancel
-}
-
-// writeRunManifest builds a LocalManifest from the loop result and
-// persists it. Failures are logged but do not error — the docs themselves
-// are the deliverable.
-//
-// On terminal outcomes (Quorum/Stalled) the manifest captures the final
-// findings.md content into FindingsContent and the per-run directory is
-// removed — the manifest becomes the durable record of the run. On
-// Paused/Cancelled the per-run directory is left in place so `--continue`
-// can pick up where the run left off.
-func writeRunManifest(
- ctx context.Context,
- out io.Writer,
- runID, topic string,
- agents []string,
- startingSHA, worktreePath, findingsDoc string,
- startedAt, endedAt time.Time,
- result LoopResult,
-) {
- manifestStore, err := NewLocalManifestStore(ctx)
- if err != nil {
- logging.Debug(ctx, "investigate: open manifest store",
- slog.String("err", err.Error()), slog.String("run_id", runID))
- return
- }
- stancesByAgent := map[string]string{}
- if result.State != nil {
- for _, s := range result.State.Stances {
- stancesByAgent[s.Agent] = s.Stance
- }
- }
- if startedAt.IsZero() && result.State != nil {
- startedAt = result.State.StartedAt
- }
- if endedAt.IsZero() {
- endedAt = time.Now().UTC()
- }
-
- // Capture findings into the manifest on terminal outcomes so the
- // content survives even after the per-run dir is deleted. Failure to
- // read is logged but non-fatal — the manifest still records that
- // the run happened, just without the findings body. The per-run dir
- // is NOT cleaned up if the read fails: leaving the file behind gives
- // the user a chance to recover it manually.
- terminal := result.Outcome == OutcomeQuorum || result.Outcome == OutcomeStalled
- findingsContent := ""
- captured := false
- if terminal && findingsDoc != "" {
- data, readErr := os.ReadFile(findingsDoc) //nolint:gosec // path computed from runID + git common dir
- if readErr != nil {
- logging.Debug(ctx, "investigate: read findings for manifest capture",
- slog.String("err", readErr.Error()), slog.String("run_id", runID))
- } else {
- findingsContent = string(data)
- captured = true
- }
- }
-
- m := LocalManifest{
- RunID: runID,
- Topic: topic,
- Slug: SlugifyTopic(topic),
- StartingSHA: startingSHA,
- WorktreePath: worktreePath,
- FindingsDoc: findingsDoc,
- FindingsContent: findingsContent,
- Agents: append([]string(nil), agents...),
- Outcome: string(result.Outcome),
- StancesByAgent: stancesByAgent,
- StartedAt: startedAt,
- EndedAt: endedAt,
- }
- if writeErr := manifestStore.Write(ctx, m); writeErr != nil {
- logging.Debug(ctx, "investigate: manifest write failed",
- slog.String("err", writeErr.Error()), slog.String("run_id", runID))
- return
- }
-
- // Clean up the per-run dir only AFTER the manifest write succeeds
- // and only when the findings body was captured. This keeps failure
- // modes safe: a manifest write failure leaves the per-run dir intact
- // (for retry/inspection); a read failure leaves the file on disk so
- // the user can recover it.
- if terminal && captured && findingsDoc != "" {
- runDir := filepath.Dir(findingsDoc)
- if rmErr := os.RemoveAll(runDir); rmErr != nil {
- logging.Debug(ctx, "investigate: cleanup per-run dir",
- slog.String("err", rmErr.Error()), slog.String("run_id", runID))
- }
- }
-
- writeInvestigateFooter(out, m)
-}
-
-// writeInvestigateFooter prints the post-run summary, the findings
-// content, and how to run `trace investigate fix`. The findings
-// content comes from the manifest's embedded FindingsContent on
-// terminal outcomes (Quorum/Stalled — the per-run dir is gone); on
-// paused/cancelled outcomes findings.md is read from the per-run dir.
-func writeInvestigateFooter(w io.Writer, m LocalManifest) {
- fmt.Fprintln(w)
- if m.Outcome != "" {
- fmt.Fprintf(w, "Outcome: %s\n", m.Outcome)
- }
- // Quorum/Stalled are terminal (per-run dir cleaned, findings captured);
- // Paused/Cancelled are resumable. "complete" would mislead users into
- // thinking a paused run can't be picked up.
- switch m.Outcome {
- case string(OutcomePaused), string(OutcomeCancelled):
- fmt.Fprintln(w, "Investigation ended (resumable with `trace investigate --continue "+m.RunID+"`).")
- default:
- fmt.Fprintln(w, "Investigation complete.")
- }
- fmt.Fprintln(w)
-
- body := findingsContentFor(m)
- if body != "" {
- rendered, renderErr := mdrender.RenderForWriter(w, body)
- if renderErr != nil {
- // Fall back to raw markdown when glamour fails (malformed
- // style config, unexpected runtime).
- rendered = body
- }
- fmt.Fprint(w, rendered)
- if !strings.HasSuffix(rendered, "\n") {
- fmt.Fprintln(w)
- }
- fmt.Fprintln(w)
- }
-
- // For terminal outcomes, suggest `fix` (which feeds findings into a
- // coding agent). For paused/cancelled, `fix` would launch off stale
- // partial findings; the resume hint above is the right next step
- // instead.
- switch m.Outcome {
- case string(OutcomePaused), string(OutcomeCancelled):
- // Resume hint already emitted above.
- default:
- fmt.Fprintln(w, "To apply these findings:")
- fmt.Fprintf(w, " trace investigate fix %s\n", m.RunID)
- }
-}
-
-// findingsContentFor returns the findings body to render in the footer.
-// Prefers the manifest's embedded content (set on terminal outcomes
-// when the per-run dir has been cleaned); falls back to reading the
-// on-disk findings.md for paused/cancelled outcomes. Errors and
-// missing files both yield "" — the caller prints a shorter footer.
-func findingsContentFor(m LocalManifest) string {
- if m.FindingsContent != "" {
- return m.FindingsContent
- }
- if m.FindingsDoc == "" {
- return ""
- }
- data, err := os.ReadFile(m.FindingsDoc)
- if err != nil {
- return ""
- }
- return string(data)
-}
-
-// newRunID returns a fresh 12-hex-char run identifier, sharing the
-// checkpoint-id format used by the strategy package.
-func newRunID() (string, error) {
- cid, err := id.Generate()
- if err != nil {
- return "", fmt.Errorf("generate run ID: %w", err)
- }
- return cid.String(), nil
-}
-
-// currentHeadSHA returns the current HEAD commit hash as a 40-char hex
-// string.
-func currentHeadSHA(ctx context.Context, repoRoot string) (string, error) {
- return gitexec.HeadSHA(ctx, repoRoot) //nolint:wrapcheck // gitexec already wraps
-}
-
-// wrapSilent applies the silent-error wrapper if it is non-nil.
-func wrapSilent(fn func(error) error, err error) error {
- if fn == nil {
- return err
- }
- return fn(err)
-}
diff --git a/cli/investigate/cmd_2.go b/cli/investigate/cmd_2.go
new file mode 100644
index 0000000..049eba0
--- /dev/null
+++ b/cli/investigate/cmd_2.go
@@ -0,0 +1,360 @@
+package investigate
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/gitexec"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/mdrender"
+)
+
+// parseAgentsCSV splits a comma-separated agent list, trimming whitespace
+// and dropping empty entries.
+func parseAgentsCSV(csv string) []string {
+ parts := strings.Split(csv, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if v := strings.TrimSpace(p); v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+// verifyAgentsLaunchable confirms each agent has a non-nil Spawner AND has
+// hooks installed in the current repo.
+func verifyAgentsLaunchable(ctx context.Context, agents []string, deps Deps) error {
+ if deps.SpawnerFor == nil {
+ return errors.New("investigate: SpawnerFor not wired")
+ }
+ if deps.GetAgentsWithHooksInstalled == nil {
+ return errors.New("investigate: GetAgentsWithHooksInstalled not wired")
+ }
+ installed := deps.GetAgentsWithHooksInstalled(ctx)
+ installedSet := make(map[string]struct{}, len(installed))
+ for _, n := range installed {
+ installedSet[string(n)] = struct{}{}
+ }
+ for _, name := range agents {
+ if deps.SpawnerFor(name) == nil {
+ return fmt.Errorf("agent %q is not launchable (spawner missing)", name)
+ }
+ if _, ok := installedSet[name]; !ok {
+ return fmt.Errorf("agent %q is not launchable (run `entire configure --agent %s` first)", name, name)
+ }
+ }
+ return nil
+}
+
+// resolveTopicAndSeed turns the user's input args into a topic + (seed
+// doc path | issue link seed bytes + topic). pickerPrompt is the
+// "Investigation prompt" collected from the spawn-time multipicker; it
+// becomes the topic only when no seed-doc / --issue-link was supplied.
+// Exactly one of seedDoc / issueSeed / topic-only is set on return.
+func resolveTopicAndSeed(ctx context.Context, args []string, f runFlags, pickerPrompt string) (topic, seedDoc string, issueSeed []byte, issueTopic string, err error) {
+ switch {
+ case len(args) == 1:
+ seedDoc = args[0]
+ body, readErr := os.ReadFile(seedDoc) //nolint:gosec // path is user-supplied positional arg
+ if readErr != nil {
+ return "", "", nil, "", fmt.Errorf("read seed doc %s: %w", seedDoc, readErr)
+ }
+ topic = DeriveTopicFromSeed(body, seedDoc)
+ return topic, seedDoc, nil, "", nil
+ case strings.TrimSpace(f.issueLink) != "":
+ res, resErr := ResolveIssueLink(ctx, f.issueLink)
+ if resErr != nil {
+ return "", "", nil, "", resErr
+ }
+ return res.Topic, "", res.SeedDoc, res.Topic, nil
+ case strings.TrimSpace(pickerPrompt) != "":
+ topic = strings.TrimSpace(pickerPrompt)
+ return topic, "", nil, "", nil
+ default:
+ return "", "", nil, "", errors.New("missing investigation input: pass [seed-doc] or --issue-link, or enter an investigation prompt in the picker")
+ }
+}
+
+// topicForBootstrap returns the topic value to embed in the bootstrap
+// scaffold. The seed-doc path takes precedence (Bootstrap re-derives from
+// the seed body), and the issue-link path uses IssueLinkTopic; only the
+// topic-only path puts the resolved topic into BootstrapInput.Topic.
+func topicForBootstrap(topic, seedDoc string, issueSeed []byte) string {
+ if seedDoc != "" || len(issueSeed) > 0 {
+ return ""
+ }
+ return topic
+}
+
+// resolveDocPaths returns the absolute findings path for a run. The
+// findings doc lives alongside state.json in the per-run directory under
+// the git common dir:
+//
+// /trace-investigations//findings.md
+// /trace-investigations//state.json
+//
+// Putting the per-run artefacts under the git common dir (rather than the
+// worktree's .trace/investigations/) keeps the worktree's working tree
+// clean — investigation findings are session-scoped scratch space, not
+// part of the user's source tree.
+func resolveDocPaths(commonDir, runID string) string {
+ return filepath.Join(commonDir, InvestigationsDirName, runID, "findings.md")
+}
+
+// executeLoopAndCapture runs the loop and returns the LoopResult so the
+// caller can use it to compose a post-run manifest / footer.
+func executeLoopAndCapture(ctx context.Context, cmd *cobra.Command, in LoopInput, deps Deps) (LoopResult, error) {
+ stateStore, err := NewStateStore(ctx)
+ if err != nil {
+ return LoopResult{}, fmt.Errorf("open run state store: %w", err)
+ }
+
+ out := cmd.OutOrStdout()
+ progress, tuiSink, runCtx, cancelTUI := buildProgressSink(ctx, in, out)
+ // Defers run LIFO. Register Wait first so cancelTUI fires BEFORE Wait
+ // — Wait blocks on the Bubble Tea program exiting, and the ctx-watcher
+ // in Start() needs ctx cancelled to push tea.Quit when no RunFinished
+ // arrives (early loop return, validation error, etc.).
+ if tuiSink != nil {
+ tuiSink.Start(runCtx)
+ defer tuiSink.Wait()
+ }
+ if cancelTUI != nil {
+ defer cancelTUI()
+ }
+
+ ldeps := LoopDeps{
+ SpawnerFor: deps.SpawnerFor,
+ States: stateStore,
+ Progress: progress,
+ }
+
+ runner := deps.LoopRun
+ if runner == nil {
+ runner = RunInvestigateLoop
+ }
+ result, runErr := runner(runCtx, in, ldeps)
+ if runErr != nil {
+ return result, fmt.Errorf("investigate loop: %w", runErr)
+ }
+ return result, nil
+}
+
+// buildProgressSink chooses between the Bubble Tea TUI and the plain-text
+// fallback based on terminal capability. In TTY mode ctx is wrapped in a
+// cancellable child so the in-TUI Ctrl+C handler can stop the run via the
+// same cancel function the cobra root would use on SIGINT. In non-TTY mode
+// the caller's ctx is returned unchanged and cancelTUI is nil.
+func buildProgressSink(ctx context.Context, in LoopInput, out io.Writer) (ProgressSink, *tuiProgressSink, context.Context, context.CancelFunc) { //nolint:ireturn // returns interface by design
+ if !interactive.IsTerminalWriter(out) || !interactive.CanPromptInteractively() {
+ return newTextProgressSink(out), nil, ctx, nil
+ }
+ runCtx, cancel := context.WithCancel(ctx)
+ maxTurns := in.MaxTurns
+ if maxTurns == 0 {
+ maxTurns = defaultMaxTurns
+ }
+ quorum := in.Quorum
+ if quorum == 0 {
+ quorum = len(in.Agents)
+ }
+ sink := newTUIProgressSink(in.Topic, in.RunID, in.Agents, maxTurns, quorum, cancel, out)
+ return sink, sink, runCtx, cancel
+}
+
+// writeRunManifest builds a LocalManifest from the loop result and
+// persists it. Failures are logged but do not error — the docs themselves
+// are the deliverable.
+//
+// On terminal outcomes (Quorum/Stalled) the manifest captures the final
+// findings.md content into FindingsContent and the per-run directory is
+// removed — the manifest becomes the durable record of the run. On
+// Paused/Cancelled the per-run directory is left in place so `--continue`
+// can pick up where the run left off.
+func writeRunManifest(
+ ctx context.Context,
+ out io.Writer,
+ runID, topic string,
+ agents []string,
+ startingSHA, worktreePath, findingsDoc string,
+ startedAt, endedAt time.Time,
+ result LoopResult,
+) {
+ manifestStore, err := NewLocalManifestStore(ctx)
+ if err != nil {
+ logging.Debug(ctx, "investigate: open manifest store",
+ slog.String("err", err.Error()), slog.String("run_id", runID))
+ return
+ }
+ stancesByAgent := map[string]string{}
+ if result.State != nil {
+ for _, s := range result.State.Stances {
+ stancesByAgent[s.Agent] = s.Stance
+ }
+ }
+ if startedAt.IsZero() && result.State != nil {
+ startedAt = result.State.StartedAt
+ }
+ if endedAt.IsZero() {
+ endedAt = time.Now().UTC()
+ }
+
+ // Capture findings into the manifest on terminal outcomes so the
+ // content survives even after the per-run dir is deleted. Failure to
+ // read is logged but non-fatal — the manifest still records that
+ // the run happened, just without the findings body. The per-run dir
+ // is NOT cleaned up if the read fails: leaving the file behind gives
+ // the user a chance to recover it manually.
+ terminal := result.Outcome == OutcomeQuorum || result.Outcome == OutcomeStalled
+ findingsContent := ""
+ captured := false
+ if terminal && findingsDoc != "" {
+ data, readErr := os.ReadFile(findingsDoc) //nolint:gosec // path computed from runID + git common dir
+ if readErr != nil {
+ logging.Debug(ctx, "investigate: read findings for manifest capture",
+ slog.String("err", readErr.Error()), slog.String("run_id", runID))
+ } else {
+ findingsContent = string(data)
+ captured = true
+ }
+ }
+
+ m := LocalManifest{
+ RunID: runID,
+ Topic: topic,
+ Slug: SlugifyTopic(topic),
+ StartingSHA: startingSHA,
+ WorktreePath: worktreePath,
+ FindingsDoc: findingsDoc,
+ FindingsContent: findingsContent,
+ Agents: append([]string(nil), agents...),
+ Outcome: string(result.Outcome),
+ StancesByAgent: stancesByAgent,
+ StartedAt: startedAt,
+ EndedAt: endedAt,
+ }
+ if writeErr := manifestStore.Write(ctx, m); writeErr != nil {
+ logging.Debug(ctx, "investigate: manifest write failed",
+ slog.String("err", writeErr.Error()), slog.String("run_id", runID))
+ return
+ }
+
+ // Clean up the per-run dir only AFTER the manifest write succeeds
+ // and only when the findings body was captured. This keeps failure
+ // modes safe: a manifest write failure leaves the per-run dir intact
+ // (for retry/inspection); a read failure leaves the file on disk so
+ // the user can recover it.
+ if terminal && captured && findingsDoc != "" {
+ runDir := filepath.Dir(findingsDoc)
+ if rmErr := os.RemoveAll(runDir); rmErr != nil {
+ logging.Debug(ctx, "investigate: cleanup per-run dir",
+ slog.String("err", rmErr.Error()), slog.String("run_id", runID))
+ }
+ }
+
+ writeInvestigateFooter(out, m)
+}
+
+// writeInvestigateFooter prints the post-run summary, the findings
+// content, and how to run `trace investigate fix`. The findings
+// content comes from the manifest's embedded FindingsContent on
+// terminal outcomes (Quorum/Stalled — the per-run dir is gone); on
+// paused/cancelled outcomes findings.md is read from the per-run dir.
+func writeInvestigateFooter(w io.Writer, m LocalManifest) {
+ fmt.Fprintln(w)
+ if m.Outcome != "" {
+ fmt.Fprintf(w, "Outcome: %s\n", m.Outcome)
+ }
+ // Quorum/Stalled are terminal (per-run dir cleaned, findings captured);
+ // Paused/Cancelled are resumable. "complete" would mislead users into
+ // thinking a paused run can't be picked up.
+ switch m.Outcome {
+ case string(OutcomePaused), string(OutcomeCancelled):
+ fmt.Fprintln(w, "Investigation ended (resumable with `trace investigate --continue "+m.RunID+"`).")
+ default:
+ fmt.Fprintln(w, "Investigation complete.")
+ }
+ fmt.Fprintln(w)
+
+ body := findingsContentFor(m)
+ if body != "" {
+ rendered, renderErr := mdrender.RenderForWriter(w, body)
+ if renderErr != nil {
+ // Fall back to raw markdown when glamour fails (malformed
+ // style config, unexpected runtime).
+ rendered = body
+ }
+ fmt.Fprint(w, rendered)
+ if !strings.HasSuffix(rendered, "\n") {
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintln(w)
+ }
+
+ // For terminal outcomes, suggest `fix` (which feeds findings into a
+ // coding agent). For paused/cancelled, `fix` would launch off stale
+ // partial findings; the resume hint above is the right next step
+ // instead.
+ switch m.Outcome {
+ case string(OutcomePaused), string(OutcomeCancelled):
+ // Resume hint already emitted above.
+ default:
+ fmt.Fprintln(w, "To apply these findings:")
+ fmt.Fprintf(w, " trace investigate fix %s\n", m.RunID)
+ }
+}
+
+// findingsContentFor returns the findings body to render in the footer.
+// Prefers the manifest's embedded content (set on terminal outcomes
+// when the per-run dir has been cleaned); falls back to reading the
+// on-disk findings.md for paused/cancelled outcomes. Errors and
+// missing files both yield "" — the caller prints a shorter footer.
+func findingsContentFor(m LocalManifest) string {
+ if m.FindingsContent != "" {
+ return m.FindingsContent
+ }
+ if m.FindingsDoc == "" {
+ return ""
+ }
+ data, err := os.ReadFile(m.FindingsDoc)
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
+// newRunID returns a fresh 12-hex-char run identifier, sharing the
+// checkpoint-id format used by the strategy package.
+func newRunID() (string, error) {
+ cid, err := id.Generate()
+ if err != nil {
+ return "", fmt.Errorf("generate run ID: %w", err)
+ }
+ return cid.String(), nil
+}
+
+// currentHeadSHA returns the current HEAD commit hash as a 40-char hex
+// string.
+func currentHeadSHA(ctx context.Context, repoRoot string) (string, error) {
+ return gitexec.HeadSHA(ctx, repoRoot) //nolint:wrapcheck // gitexec already wraps
+}
+
+// wrapSilent applies the silent-error wrapper if it is non-nil.
+func wrapSilent(fn func(error) error, err error) error {
+ if fn == nil {
+ return err
+ }
+ return fn(err)
+}
diff --git a/cli/lifecycle.go b/cli/lifecycle.go
index 7da4a68..5fc0678 100644
--- a/cli/lifecycle.go
+++ b/cli/lifecycle.go
@@ -9,14 +9,12 @@ package cli
import (
"context"
- "crypto/sha256"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
- "time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/types"
@@ -24,7 +22,6 @@ import (
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/strategy"
- "github.com/GrayCodeAI/trace/cli/transcript"
"github.com/GrayCodeAI/trace/cli/validation"
"github.com/GrayCodeAI/trace/cli/webhook"
"github.com/GrayCodeAI/trace/perf"
@@ -767,288 +764,3 @@ func handleLifecycleSubagentStart(ctx context.Context, ag agent.Agent, event *ag
return nil
}
-
-// handleLifecycleSubagentEnd handles subagent end: detects changes, saves task checkpoint.
-func handleLifecycleSubagentEnd(ctx context.Context, ag agent.Agent, event *agent.Event) error {
- logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name())
- if event.SubagentType == "" && event.TaskDescription == "" {
- // Extract subagent type and description from tool input
- event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput)
- }
-
- // Determine subagent transcript path
- transcriptDir := filepath.Dir(event.SessionRef)
- var subagentTranscriptPath string
- if event.SubagentID != "" {
- subagentTranscriptPath = AgentTranscriptPath(transcriptDir, event.SubagentID)
- if !fileExists(subagentTranscriptPath) {
- subagentTranscriptPath = ""
- }
- }
-
- // Log context
- subagentEndAttrs := []any{
- slog.String("event", event.Type.String()),
- slog.String("session_id", event.SessionID),
- slog.String("tool_use_id", event.ToolUseID),
- }
- if event.SubagentID != "" {
- subagentEndAttrs = append(subagentEndAttrs, slog.String("agent_id", event.SubagentID))
- }
- if subagentTranscriptPath != "" {
- subagentEndAttrs = append(subagentEndAttrs, slog.String("subagent_transcript", subagentTranscriptPath))
- }
- logging.Info(logCtx, "subagent completed", subagentEndAttrs...)
-
- // Extract modified files from hook payload and/or subagent transcript
- var modifiedFiles []string
- modifiedFiles = append(modifiedFiles, event.ModifiedFiles...)
- if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok {
- transcriptToScan := event.SessionRef
- if subagentTranscriptPath != "" {
- transcriptToScan = subagentTranscriptPath
- }
- if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptToScan, 0); fileErr != nil {
- logging.Warn(logCtx, "failed to extract modified files from subagent",
- slog.String("error", fileErr.Error()))
- } else {
- modifiedFiles = mergeUnique(modifiedFiles, files)
- }
- }
-
- // Load pre-task state and detect file changes.
- // If no pre-task state exists (agent doesn't support pre-task hook), fall back
- // to the session's pre-prompt state. Without either, DetectFileChanges receives
- // nil and treats ALL untracked files as new — which would create spurious task
- // checkpoints for pre-existing untracked files (e.g., .github/hooks/trace.json).
- preState, err := LoadPreTaskState(ctx, event.ToolUseID)
- if err != nil {
- logging.Warn(logCtx, "failed to load pre-task state",
- slog.String("error", err.Error()))
- }
- var preUntrackedFiles []string
- if preState != nil {
- preUntrackedFiles = preState.PreUntrackedFiles()
- }
- changes, err := DetectFileChanges(ctx, preUntrackedFiles)
- if err != nil {
- logging.Warn(logCtx, "failed to compute file changes",
- slog.String("error", err.Error()))
- }
-
- // Get worktree root and normalize paths
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return fmt.Errorf("failed to get worktree root: %w", err)
- }
-
- relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot)
- var relNewFiles, relDeletedFiles []string
- if changes != nil {
- relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot)
- relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot)
- relModifiedFiles = mergeUnique(relModifiedFiles, FilterAndNormalizePaths(changes.Modified, repoRoot))
- }
-
- // If no changes, skip
- if len(relModifiedFiles) == 0 && len(relNewFiles) == 0 && len(relDeletedFiles) == 0 {
- logging.Info(logCtx, "no file changes detected, skipping task checkpoint")
- _ = CleanupPreTaskState(ctx, event.ToolUseID) //nolint:errcheck // best-effort cleanup
- return nil
- }
-
- // Find checkpoint UUID from main transcript (best-effort)
- var checkpointUUID string
- // Use the existing CLI-level checkpoint UUID finder
- mainLines, _ := parseTranscriptForCheckpointUUID(event.SessionRef) //nolint:errcheck // best-effort
- if mainLines != nil {
- checkpointUUID, _ = FindCheckpointUUID(mainLines, event.ToolUseID)
- }
-
- // Get git author
- author, err := GetGitAuthor(ctx)
- if err != nil {
- return fmt.Errorf("failed to get git author: %w", err)
- }
-
- // Build task checkpoint context
- start := GetStrategy(ctx)
- agentType := ag.Type()
-
- taskStepCtx := strategy.TaskStepContext{
- SessionID: event.SessionID,
- ToolUseID: event.ToolUseID,
- AgentID: event.SubagentID,
- ModifiedFiles: relModifiedFiles,
- NewFiles: relNewFiles,
- DeletedFiles: relDeletedFiles,
- TranscriptPath: event.SessionRef,
- SubagentTranscriptPath: subagentTranscriptPath,
- CheckpointUUID: checkpointUUID,
- AuthorName: author.Name,
- AuthorEmail: author.Email,
- SubagentType: event.SubagentType,
- TaskDescription: event.TaskDescription,
- AgentType: agentType,
- }
-
- if err := start.SaveTaskStep(ctx, taskStepCtx); err != nil {
- return fmt.Errorf("failed to save task step: %w", err)
- }
-
- _ = CleanupPreTaskState(ctx, event.ToolUseID) //nolint:errcheck // best-effort cleanup
- return nil
-}
-
-// --- Helper functions ---
-
-// resolveTranscriptOffset determines the transcript offset to use for parsing.
-// Prefers pre-prompt state, falls back to session state.
-func resolveTranscriptOffset(ctx context.Context, preState *PrePromptState, sessionID string) int {
- logCtx := logging.WithComponent(ctx, "lifecycle")
- if preState != nil && preState.TranscriptOffset > 0 {
- logging.Debug(logCtx, "pre-prompt state found, parsing transcript from offset",
- slog.Int("offset", preState.TranscriptOffset))
- return preState.TranscriptOffset
- }
-
- // Fall back to session state
- sessionState, loadErr := strategy.LoadSessionState(ctx, sessionID)
- if loadErr != nil {
- logging.Warn(logCtx, "failed to load session state",
- slog.String("error", loadErr.Error()))
- return 0
- }
- if sessionState != nil && sessionState.CheckpointTranscriptStart > 0 {
- logging.Debug(logCtx, "session state found, parsing transcript from offset",
- slog.Int("offset", sessionState.CheckpointTranscriptStart))
- return sessionState.CheckpointTranscriptStart
- }
-
- return 0
-}
-
-// parseTranscriptForCheckpointUUID is a thin wrapper around transcript parsing for checkpoint UUID lookup.
-// Returns parsed transcript lines for use with FindCheckpointUUID.
-func parseTranscriptForCheckpointUUID(transcriptPath string) ([]transcriptLine, error) {
- lines, err := transcript.ParseFromFileAtLine(transcriptPath, 0)
- if err != nil {
- return nil, fmt.Errorf("parsing transcript for checkpoint UUID: %w", err)
- }
- return lines, nil
-}
-
-// transitionSessionTurnEnd transitions the session phase to IDLE and dispatches turn-end actions.
-func transitionSessionTurnEnd(ctx context.Context, sessionID string, event *agent.Event) {
- logCtx := logging.WithComponent(ctx, "lifecycle")
- turnState, loadErr := strategy.LoadSessionState(ctx, sessionID)
- if loadErr != nil {
- logging.Warn(logCtx, "failed to load session state for turn end",
- slog.String("error", loadErr.Error()))
- return
- }
- if turnState == nil {
- return
- }
-
- persistEventMetadataToState(event, turnState)
-
- if err := strategy.TransitionAndLog(ctx, turnState, session.EventTurnEnd, session.TransitionContext{}, session.NoOpActionHandler{}); err != nil {
- logging.Warn(logCtx, "turn-end transition failed",
- slog.String("error", err.Error()))
- }
-
- // Always dispatch to strategy for turn-end handling. The strategy reads
- // work items from state (e.g. TurnCheckpointIDs), not the action list.
- start := GetStrategy(ctx)
- if err := start.HandleTurnEnd(ctx, turnState); err != nil {
- logging.Warn(logCtx, "turn-end action dispatch failed",
- slog.String("error", err.Error()))
- }
-
- if updateErr := strategy.SaveSessionState(ctx, turnState); updateErr != nil {
- logging.Warn(logCtx, "failed to update session phase on turn end",
- slog.String("error", updateErr.Error()))
- }
-}
-
-// markSessionEnded transitions the session to ENDED phase via the state machine.
-// If event is non-nil, hook-provided metrics are persisted to state before saving.
-func markSessionEnded(ctx context.Context, event *agent.Event, sessionID string) error {
- state, err := strategy.LoadSessionState(ctx, sessionID)
- if err != nil {
- return fmt.Errorf("failed to load session state: %w", err)
- }
- if state == nil {
- return nil // No state file, nothing to update
- }
-
- if event != nil {
- persistEventMetadataToState(event, state)
- }
-
- if transErr := strategy.TransitionAndLog(ctx, state, session.EventSessionStop, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
- logging.Warn(logging.WithComponent(ctx, "lifecycle"), "session stop transition failed",
- slog.String("error", transErr.Error()))
- }
-
- now := time.Now()
- state.EndedAt = &now
-
- if err := strategy.SaveSessionState(ctx, state); err != nil {
- return fmt.Errorf("failed to save session state: %w", err)
- }
- return nil
-}
-
-// logFileChanges logs the files modified, created, and deleted during a session.
-func logFileChanges(ctx context.Context, modified, newFiles, deleted []string) {
- logCtx := logging.WithComponent(ctx, "lifecycle")
- logging.Debug(logCtx, "files changed during session",
- slog.Int("modified", len(modified)),
- slog.Int("new", len(newFiles)),
- slog.Int("deleted", len(deleted)))
-}
-
-func persistEventMetadataToState(event *agent.Event, state *strategy.SessionState) {
- // Update ModelName if provided (model is known by turn-end even on first turn)
- if event.Model != "" {
- state.ModelName = event.Model
- }
-
- // Persist hook-provided session metrics (e.g., from Cursor hooks)
- if event.DurationMs > 0 {
- state.SessionDurationMs = event.DurationMs
- }
- // Use hook-reported turn count if available (take max); otherwise
- // increment on each TurnEnd event to count turns ourselves.
- if event.TurnCount > 0 {
- if event.TurnCount > state.SessionTurnCount {
- state.SessionTurnCount = event.TurnCount
- }
- } else if event.Type == agent.TurnEnd {
- state.SessionTurnCount++
- }
- if event.ContextTokens > 0 {
- state.ContextTokens = event.ContextTokens
- }
- if event.ContextWindowSize > 0 {
- state.ContextWindowSize = event.ContextWindowSize
- }
-}
-
-// xorObfuscate applies XOR obfuscation to data using a key derived from the
-// session ID. Because XOR is its own inverse, calling this function twice with
-// the same sessionID returns the original data.
-//
-// NOTE: This is obfuscation, NOT encryption. It deters casual reading of
-// prompt.txt on disk but provides no real security against a determined
-// attacker who can read the source code or the session ID.
-func xorObfuscate(data []byte, sessionID string) []byte {
- hash := sha256.Sum256([]byte(sessionID))
- result := make([]byte, len(data))
- for i, b := range data {
- result[i] = b ^ hash[i%len(hash)]
- }
- return result
-}
diff --git a/cli/lifecycle_2.go b/cli/lifecycle_2.go
new file mode 100644
index 0000000..0500960
--- /dev/null
+++ b/cli/lifecycle_2.go
@@ -0,0 +1,302 @@
+package cli
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "log/slog"
+ "path/filepath"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/transcript"
+)
+
+// handleLifecycleSubagentEnd handles subagent end: detects changes, saves task checkpoint.
+func handleLifecycleSubagentEnd(ctx context.Context, ag agent.Agent, event *agent.Event) error {
+ logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name())
+ if event.SubagentType == "" && event.TaskDescription == "" {
+ // Extract subagent type and description from tool input
+ event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput)
+ }
+
+ // Determine subagent transcript path
+ transcriptDir := filepath.Dir(event.SessionRef)
+ var subagentTranscriptPath string
+ if event.SubagentID != "" {
+ subagentTranscriptPath = AgentTranscriptPath(transcriptDir, event.SubagentID)
+ if !fileExists(subagentTranscriptPath) {
+ subagentTranscriptPath = ""
+ }
+ }
+
+ // Log context
+ subagentEndAttrs := []any{
+ slog.String("event", event.Type.String()),
+ slog.String("session_id", event.SessionID),
+ slog.String("tool_use_id", event.ToolUseID),
+ }
+ if event.SubagentID != "" {
+ subagentEndAttrs = append(subagentEndAttrs, slog.String("agent_id", event.SubagentID))
+ }
+ if subagentTranscriptPath != "" {
+ subagentEndAttrs = append(subagentEndAttrs, slog.String("subagent_transcript", subagentTranscriptPath))
+ }
+ logging.Info(logCtx, "subagent completed", subagentEndAttrs...)
+
+ // Extract modified files from hook payload and/or subagent transcript
+ var modifiedFiles []string
+ modifiedFiles = append(modifiedFiles, event.ModifiedFiles...)
+ if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok {
+ transcriptToScan := event.SessionRef
+ if subagentTranscriptPath != "" {
+ transcriptToScan = subagentTranscriptPath
+ }
+ if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptToScan, 0); fileErr != nil {
+ logging.Warn(logCtx, "failed to extract modified files from subagent",
+ slog.String("error", fileErr.Error()))
+ } else {
+ modifiedFiles = mergeUnique(modifiedFiles, files)
+ }
+ }
+
+ // Load pre-task state and detect file changes.
+ // If no pre-task state exists (agent doesn't support pre-task hook), fall back
+ // to the session's pre-prompt state. Without either, DetectFileChanges receives
+ // nil and treats ALL untracked files as new — which would create spurious task
+ // checkpoints for pre-existing untracked files (e.g., .github/hooks/trace.json).
+ preState, err := LoadPreTaskState(ctx, event.ToolUseID)
+ if err != nil {
+ logging.Warn(logCtx, "failed to load pre-task state",
+ slog.String("error", err.Error()))
+ }
+ var preUntrackedFiles []string
+ if preState != nil {
+ preUntrackedFiles = preState.PreUntrackedFiles()
+ }
+ changes, err := DetectFileChanges(ctx, preUntrackedFiles)
+ if err != nil {
+ logging.Warn(logCtx, "failed to compute file changes",
+ slog.String("error", err.Error()))
+ }
+
+ // Get worktree root and normalize paths
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get worktree root: %w", err)
+ }
+
+ relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot)
+ var relNewFiles, relDeletedFiles []string
+ if changes != nil {
+ relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot)
+ relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot)
+ relModifiedFiles = mergeUnique(relModifiedFiles, FilterAndNormalizePaths(changes.Modified, repoRoot))
+ }
+
+ // If no changes, skip
+ if len(relModifiedFiles) == 0 && len(relNewFiles) == 0 && len(relDeletedFiles) == 0 {
+ logging.Info(logCtx, "no file changes detected, skipping task checkpoint")
+ _ = CleanupPreTaskState(ctx, event.ToolUseID) //nolint:errcheck // best-effort cleanup
+ return nil
+ }
+
+ // Find checkpoint UUID from main transcript (best-effort)
+ var checkpointUUID string
+ // Use the existing CLI-level checkpoint UUID finder
+ mainLines, _ := parseTranscriptForCheckpointUUID(event.SessionRef) //nolint:errcheck // best-effort
+ if mainLines != nil {
+ checkpointUUID, _ = FindCheckpointUUID(mainLines, event.ToolUseID)
+ }
+
+ // Get git author
+ author, err := GetGitAuthor(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get git author: %w", err)
+ }
+
+ // Build task checkpoint context
+ start := GetStrategy(ctx)
+ agentType := ag.Type()
+
+ taskStepCtx := strategy.TaskStepContext{
+ SessionID: event.SessionID,
+ ToolUseID: event.ToolUseID,
+ AgentID: event.SubagentID,
+ ModifiedFiles: relModifiedFiles,
+ NewFiles: relNewFiles,
+ DeletedFiles: relDeletedFiles,
+ TranscriptPath: event.SessionRef,
+ SubagentTranscriptPath: subagentTranscriptPath,
+ CheckpointUUID: checkpointUUID,
+ AuthorName: author.Name,
+ AuthorEmail: author.Email,
+ SubagentType: event.SubagentType,
+ TaskDescription: event.TaskDescription,
+ AgentType: agentType,
+ }
+
+ if err := start.SaveTaskStep(ctx, taskStepCtx); err != nil {
+ return fmt.Errorf("failed to save task step: %w", err)
+ }
+
+ _ = CleanupPreTaskState(ctx, event.ToolUseID) //nolint:errcheck // best-effort cleanup
+ return nil
+}
+
+// --- Helper functions ---
+
+// resolveTranscriptOffset determines the transcript offset to use for parsing.
+// Prefers pre-prompt state, falls back to session state.
+func resolveTranscriptOffset(ctx context.Context, preState *PrePromptState, sessionID string) int {
+ logCtx := logging.WithComponent(ctx, "lifecycle")
+ if preState != nil && preState.TranscriptOffset > 0 {
+ logging.Debug(logCtx, "pre-prompt state found, parsing transcript from offset",
+ slog.Int("offset", preState.TranscriptOffset))
+ return preState.TranscriptOffset
+ }
+
+ // Fall back to session state
+ sessionState, loadErr := strategy.LoadSessionState(ctx, sessionID)
+ if loadErr != nil {
+ logging.Warn(logCtx, "failed to load session state",
+ slog.String("error", loadErr.Error()))
+ return 0
+ }
+ if sessionState != nil && sessionState.CheckpointTranscriptStart > 0 {
+ logging.Debug(logCtx, "session state found, parsing transcript from offset",
+ slog.Int("offset", sessionState.CheckpointTranscriptStart))
+ return sessionState.CheckpointTranscriptStart
+ }
+
+ return 0
+}
+
+// parseTranscriptForCheckpointUUID is a thin wrapper around transcript parsing for checkpoint UUID lookup.
+// Returns parsed transcript lines for use with FindCheckpointUUID.
+func parseTranscriptForCheckpointUUID(transcriptPath string) ([]transcriptLine, error) {
+ lines, err := transcript.ParseFromFileAtLine(transcriptPath, 0)
+ if err != nil {
+ return nil, fmt.Errorf("parsing transcript for checkpoint UUID: %w", err)
+ }
+ return lines, nil
+}
+
+// transitionSessionTurnEnd transitions the session phase to IDLE and dispatches turn-end actions.
+func transitionSessionTurnEnd(ctx context.Context, sessionID string, event *agent.Event) {
+ logCtx := logging.WithComponent(ctx, "lifecycle")
+ turnState, loadErr := strategy.LoadSessionState(ctx, sessionID)
+ if loadErr != nil {
+ logging.Warn(logCtx, "failed to load session state for turn end",
+ slog.String("error", loadErr.Error()))
+ return
+ }
+ if turnState == nil {
+ return
+ }
+
+ persistEventMetadataToState(event, turnState)
+
+ if err := strategy.TransitionAndLog(ctx, turnState, session.EventTurnEnd, session.TransitionContext{}, session.NoOpActionHandler{}); err != nil {
+ logging.Warn(logCtx, "turn-end transition failed",
+ slog.String("error", err.Error()))
+ }
+
+ // Always dispatch to strategy for turn-end handling. The strategy reads
+ // work items from state (e.g. TurnCheckpointIDs), not the action list.
+ start := GetStrategy(ctx)
+ if err := start.HandleTurnEnd(ctx, turnState); err != nil {
+ logging.Warn(logCtx, "turn-end action dispatch failed",
+ slog.String("error", err.Error()))
+ }
+
+ if updateErr := strategy.SaveSessionState(ctx, turnState); updateErr != nil {
+ logging.Warn(logCtx, "failed to update session phase on turn end",
+ slog.String("error", updateErr.Error()))
+ }
+}
+
+// markSessionEnded transitions the session to ENDED phase via the state machine.
+// If event is non-nil, hook-provided metrics are persisted to state before saving.
+func markSessionEnded(ctx context.Context, event *agent.Event, sessionID string) error {
+ state, err := strategy.LoadSessionState(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("failed to load session state: %w", err)
+ }
+ if state == nil {
+ return nil // No state file, nothing to update
+ }
+
+ if event != nil {
+ persistEventMetadataToState(event, state)
+ }
+
+ if transErr := strategy.TransitionAndLog(ctx, state, session.EventSessionStop, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
+ logging.Warn(logging.WithComponent(ctx, "lifecycle"), "session stop transition failed",
+ slog.String("error", transErr.Error()))
+ }
+
+ now := time.Now()
+ state.EndedAt = &now
+
+ if err := strategy.SaveSessionState(ctx, state); err != nil {
+ return fmt.Errorf("failed to save session state: %w", err)
+ }
+ return nil
+}
+
+// logFileChanges logs the files modified, created, and deleted during a session.
+func logFileChanges(ctx context.Context, modified, newFiles, deleted []string) {
+ logCtx := logging.WithComponent(ctx, "lifecycle")
+ logging.Debug(logCtx, "files changed during session",
+ slog.Int("modified", len(modified)),
+ slog.Int("new", len(newFiles)),
+ slog.Int("deleted", len(deleted)))
+}
+
+func persistEventMetadataToState(event *agent.Event, state *strategy.SessionState) {
+ // Update ModelName if provided (model is known by turn-end even on first turn)
+ if event.Model != "" {
+ state.ModelName = event.Model
+ }
+
+ // Persist hook-provided session metrics (e.g., from Cursor hooks)
+ if event.DurationMs > 0 {
+ state.SessionDurationMs = event.DurationMs
+ }
+ // Use hook-reported turn count if available (take max); otherwise
+ // increment on each TurnEnd event to count turns ourselves.
+ if event.TurnCount > 0 {
+ if event.TurnCount > state.SessionTurnCount {
+ state.SessionTurnCount = event.TurnCount
+ }
+ } else if event.Type == agent.TurnEnd {
+ state.SessionTurnCount++
+ }
+ if event.ContextTokens > 0 {
+ state.ContextTokens = event.ContextTokens
+ }
+ if event.ContextWindowSize > 0 {
+ state.ContextWindowSize = event.ContextWindowSize
+ }
+}
+
+// xorObfuscate applies XOR obfuscation to data using a key derived from the
+// session ID. Because XOR is its own inverse, calling this function twice with
+// the same sessionID returns the original data.
+//
+// NOTE: This is obfuscation, NOT encryption. It deters casual reading of
+// prompt.txt on disk but provides no real security against a determined
+// attacker who can read the source code or the session ID.
+func xorObfuscate(data []byte, sessionID string) []byte {
+ hash := sha256.Sum256([]byte(sessionID))
+ result := make([]byte, len(data))
+ for i, b := range data {
+ result[i] = b ^ hash[i%len(hash)]
+ }
+ return result
+}
diff --git a/cli/migrate.go b/cli/migrate.go
index 3ab9b5d..d1a3647 100644
--- a/cli/migrate.go
+++ b/cli/migrate.go
@@ -1,7 +1,6 @@
package cli
import (
- "bytes"
"context"
"crypto/sha256"
"errors"
@@ -9,7 +8,6 @@ import (
"io"
"log/slog"
"sort"
- "strconv"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/checkpoint"
@@ -17,8 +15,6 @@ import (
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/strategy"
- "github.com/GrayCodeAI/trace/cli/transcript/compact"
- "github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
@@ -806,558 +802,3 @@ func addRecomputedGenerationJSON(v2Store *checkpoint.V2GitStore, treeHash plumbi
}
return newTreeHash, nil
}
-
-func pruneCheckpointFromRoot(repo *git.Repository, rootTreeHash plumbing.Hash, shardPrefix, shardSuffix string) (plumbing.Hash, error) {
- newRoot, err := checkpoint.UpdateSubtree(
- repo, rootTreeHash,
- []string{shardPrefix},
- nil,
- checkpoint.UpdateSubtreeOptions{
- MergeMode: checkpoint.MergeKeepExisting,
- DeleteNames: []string{shardSuffix},
- },
- )
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to prune checkpoint from shard: %w", err)
- }
- if newRoot == rootTreeHash {
- return newRoot, nil
- }
-
- newRootTree, err := repo.TreeObject(newRoot)
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to read pruned root tree: %w", err)
- }
- shardTree, err := newRootTree.Tree(shardPrefix)
- if err != nil {
- return newRoot, nil //nolint:nilerr // The shard prefix was already absent after pruning.
- }
- if len(shardTree.Entries) > 0 {
- return newRoot, nil
- }
-
- prunedRoot, err := checkpoint.UpdateSubtree(
- repo, rootTreeHash,
- nil,
- nil,
- checkpoint.UpdateSubtreeOptions{
- MergeMode: checkpoint.MergeKeepExisting,
- DeleteNames: []string{shardPrefix},
- },
- )
- if err != nil {
- return plumbing.ZeroHash, fmt.Errorf("failed to prune empty shard prefix: %w", err)
- }
- return prunedRoot, nil
-}
-
-func collectMissingFullCheckpointForPacking(
- ctx context.Context,
- repo *git.Repository,
- v1Store *checkpoint.GitStore,
- v2Store *checkpoint.V2GitStore,
- info checkpoint.CommittedInfo,
- v2Summary *checkpoint.CheckpointSummary,
-) (*migratedFullCheckpoint, bool, error) {
- missingSessions, err := collectMissingFullSessionsForPacking(ctx, v2Store, info.CheckpointID, v2Summary)
- if err != nil {
- return nil, false, err
- }
- if len(missingSessions) == 0 {
- return nil, false, nil
- }
-
- v1Summary, err := v1Store.ReadCommitted(ctx, info.CheckpointID)
- if err != nil {
- return nil, false, fmt.Errorf("failed to read v1 summary while checking v2 raw artifacts: %w", err)
- }
- if v1Summary == nil {
- return nil, false, fmt.Errorf("v1 checkpoint %s has no summary", info.CheckpointID)
- }
-
- v1BySessionID, err := collectV1SessionIndexesForPacking(ctx, v1Store, info.CheckpointID, v1Summary, missingSessions)
- if err != nil {
- return nil, false, err
- }
-
- fullCheckpoint := &migratedFullCheckpoint{
- checkpointID: info.CheckpointID,
- }
- v1ToV2SessionIdx := make(map[int]int)
-
- for _, missingSession := range missingSessions {
- v1Session, ok, readErr := readV1SessionForMissingFullArtifact(ctx, v1Store, info.CheckpointID, v1Summary, v1BySessionID, missingSession)
- if readErr != nil {
- return nil, false, readErr
- }
- if !ok {
- return nil, false, fmt.Errorf("failed to find v1 session for v2 session %d while checking raw artifacts", missingSession.sessionIndex)
- }
-
- fullCheckpoint.sessions = append(fullCheckpoint.sessions, migratedFullSession{
- sessionIndex: missingSession.sessionIndex,
- content: v1Session.content,
- })
- v1ToV2SessionIdx[v1Session.sessionIndex] = missingSession.sessionIndex
- }
-
- latestV2SessionIdx := len(v2Summary.Sessions) - 1
- taskTrees, taskErr := collectTaskMetadataForMigratedFullGenerationWithRootSession(
- repo,
- info.CheckpointID,
- v1Summary,
- v1ToV2SessionIdx,
- latestV2SessionIdx,
- latestV2SessionIdx >= 0,
- )
- if taskErr != nil {
- return nil, false, fmt.Errorf("failed to collect task metadata while checking raw artifacts: %w", taskErr)
- }
- fullCheckpoint.taskTrees = taskTrees
-
- return fullCheckpoint, true, nil
-}
-
-type missingFullSessionForPacking struct {
- sessionIndex int
- sessionID string
-}
-
-type v1SessionForPacking struct {
- sessionIndex int
- content *checkpoint.SessionContent
-}
-
-func collectMissingFullSessionsForPacking(
- ctx context.Context,
- v2Store *checkpoint.V2GitStore,
- checkpointID id.CheckpointID,
- summary *checkpoint.CheckpointSummary,
-) ([]missingFullSessionForPacking, error) {
- missingSessions := make([]missingFullSessionForPacking, 0)
- for sessionIdx := range len(summary.Sessions) {
- ok, checkErr := hasFullSessionArtifacts(v2Store, checkpointID, sessionIdx)
- if checkErr != nil {
- return nil, fmt.Errorf("failed to check v2 session %d artifacts: %w", sessionIdx, checkErr)
- }
- if ok {
- continue
- }
-
- v2Content, readErr := v2Store.ReadSessionMetadataAndPrompts(ctx, checkpointID, sessionIdx)
- if readErr != nil {
- return nil, fmt.Errorf("failed to read v2 session %d metadata while checking raw artifacts: %w", sessionIdx, readErr)
- }
-
- missingSessions = append(missingSessions, missingFullSessionForPacking{
- sessionIndex: sessionIdx,
- sessionID: v2Content.Metadata.SessionID,
- })
- }
-
- return missingSessions, nil
-}
-
-func collectV1SessionIndexesForPacking(
- ctx context.Context,
- v1Store *checkpoint.GitStore,
- checkpointID id.CheckpointID,
- summary *checkpoint.CheckpointSummary,
- missingSessions []missingFullSessionForPacking,
-) (map[string][]int, error) {
- neededSessionIDs := make(map[string]struct{})
- for _, session := range missingSessions {
- if session.sessionID != "" {
- neededSessionIDs[session.sessionID] = struct{}{}
- }
- }
-
- bySessionID := make(map[string][]int)
- if len(neededSessionIDs) == 0 {
- return bySessionID, nil
- }
-
- for sessionIdx := range len(summary.Sessions) {
- metadata, err := v1Store.ReadSessionMetadata(ctx, checkpointID, sessionIdx)
- if err != nil {
- if ctxErr := ctx.Err(); ctxErr != nil {
- return nil, fmt.Errorf("context canceled while reading v1 session metadata: %w", ctxErr)
- }
- continue
- }
- if _, ok := neededSessionIDs[metadata.SessionID]; ok {
- bySessionID[metadata.SessionID] = append(bySessionID[metadata.SessionID], sessionIdx)
- }
- }
-
- return bySessionID, nil
-}
-
-func readV1SessionForMissingFullArtifact(
- ctx context.Context,
- v1Store *checkpoint.GitStore,
- checkpointID id.CheckpointID,
- summary *checkpoint.CheckpointSummary,
- bySessionID map[string][]int,
- missingSession missingFullSessionForPacking,
-) (v1SessionForPacking, bool, error) {
- var triedSessionIndexes map[int]struct{}
- if missingSession.sessionID != "" {
- indexes := bySessionID[missingSession.sessionID]
- triedSessionIndexes = make(map[int]struct{}, len(indexes))
- for i := len(indexes) - 1; i >= 0; i-- {
- sessionIdx := indexes[i]
- triedSessionIndexes[sessionIdx] = struct{}{}
- session, found, err := readV1SessionForPacking(ctx, v1Store, checkpointID, sessionIdx)
- if err != nil || found {
- return session, found, err
- }
- }
- }
-
- if missingSession.sessionIndex >= len(summary.Sessions) {
- return v1SessionForPacking{}, false, nil
- }
- if _, tried := triedSessionIndexes[missingSession.sessionIndex]; tried {
- return v1SessionForPacking{}, false, nil
- }
- return readV1SessionForPacking(ctx, v1Store, checkpointID, missingSession.sessionIndex)
-}
-
-func readV1SessionForPacking(
- ctx context.Context,
- v1Store *checkpoint.GitStore,
- checkpointID id.CheckpointID,
- sessionIdx int,
-) (v1SessionForPacking, bool, error) {
- content, err := v1Store.ReadSessionContent(ctx, checkpointID, sessionIdx)
- if err != nil {
- if errors.Is(err, checkpoint.ErrNoTranscript) || errors.Is(err, checkpoint.ErrCheckpointNotFound) {
- return v1SessionForPacking{}, false, nil
- }
- return v1SessionForPacking{}, false, fmt.Errorf("failed to read v1 session %d while checking raw artifacts: %w", sessionIdx, err)
- }
-
- return v1SessionForPacking{
- sessionIndex: sessionIdx,
- content: content,
- }, true, nil
-}
-
-func hasFullSessionArtifacts(v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) (bool, error) {
- ok, err := v2Store.HasFullSessionArtifacts(cpID, sessionIdx)
- if err != nil {
- return false, fmt.Errorf("failed to check v2 full artifacts for session %d: %w", sessionIdx, err)
- }
- return ok, nil
-}
-
-// backfillCompactTranscripts checks sessions in an already-migrated v2 checkpoint
-// for missing transcript.jsonl and attempts to generate + write them from v1 data.
-// Returns errAlreadyMigrated if all sessions already have compact transcripts.
-func backfillCompactTranscripts(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, info checkpoint.CommittedInfo, v2Summary *checkpoint.CheckpointSummary) (int, error) {
- // Find sessions missing transcript.jsonl
- var needsBackfill []int
- for i, session := range v2Summary.Sessions {
- if session.Transcript == "" {
- needsBackfill = append(needsBackfill, i)
- }
- }
-
- if len(needsBackfill) == 0 {
- return 0, errAlreadyMigrated
- }
-
- backfilled := 0
- var lastAgent string
-
- for _, sessionIdx := range needsBackfill {
- content, readErr := v1Store.ReadSessionContent(ctx, info.CheckpointID, sessionIdx)
- if readErr != nil {
- logging.Warn(
- ctx, "transcript.jsonl backfill: could not read v1 session",
- slog.String("checkpoint_id", string(info.CheckpointID)),
- slog.Int("session_index", sessionIdx),
- slog.String("error", readErr.Error()),
- )
- continue
- }
-
- if content.Metadata.Agent != "" {
- lastAgent = string(content.Metadata.Agent)
- }
-
- compacted := tryCompactTranscript(ctx, content.Transcript, content.Metadata)
- if compacted == nil {
- // tryCompactTranscript already logs for no-agent and compact-error cases;
- // log the empty-transcript case here.
- if len(content.Transcript) == 0 {
- logging.Warn(
- ctx, "transcript.jsonl backfill: empty transcript in v1",
- slog.String("checkpoint_id", string(info.CheckpointID)),
- slog.Int("session_index", sessionIdx),
- )
- }
- continue
- }
-
- updateErr := v2Store.UpdateCommitted(ctx, checkpoint.UpdateCommittedOptions{
- CheckpointID: info.CheckpointID,
- SessionID: content.Metadata.SessionID,
- CompactTranscript: compacted,
- })
- if updateErr != nil {
- logging.Warn(
- ctx, "transcript.jsonl backfill: failed to write to v2",
- slog.String("checkpoint_id", string(info.CheckpointID)),
- slog.Int("session_index", sessionIdx),
- slog.String("error", updateErr.Error()),
- )
- continue
- }
-
- backfilled++
- }
-
- if backfilled == 0 {
- if lastAgent != "" {
- return 0, fmt.Errorf("%w: agent %q", errTranscriptNotGeneratable, lastAgent)
- }
- return 0, fmt.Errorf("%w: no agent type in metadata", errTranscriptNotGeneratable)
- }
-
- return backfilled, nil
-}
-
-func buildMigrateWriteOpts(content *checkpoint.SessionContent, info checkpoint.CommittedInfo, combinedAttribution *checkpoint.InitialAttribution) checkpoint.WriteCommittedOptions {
- m := content.Metadata
-
- prompts := checkpoint.SplitPromptContent(content.Prompts)
-
- return checkpoint.WriteCommittedOptions{
- CheckpointID: info.CheckpointID,
- SessionID: m.SessionID,
- CreatedAt: m.CreatedAt,
- Strategy: m.Strategy,
- Branch: m.Branch,
- // content.Transcript comes from persisted checkpoint storage and is
- // already redacted.
- Transcript: redact.AlreadyRedacted(content.Transcript),
- Prompts: prompts,
- FilesTouched: m.FilesTouched,
- CheckpointsCount: m.CheckpointsCount,
- Agent: m.Agent,
- Model: m.Model,
- TurnID: m.TurnID,
- TokenUsage: m.TokenUsage,
- SessionMetrics: m.SessionMetrics,
- InitialAttribution: m.InitialAttribution,
- PromptAttributionsJSON: m.PromptAttributions,
- CombinedAttribution: combinedAttribution,
- Summary: m.Summary,
- CheckpointTranscriptStart: m.GetTranscriptStart(),
- TranscriptIdentifierAtStart: m.TranscriptIdentifierAtStart,
- IsTask: m.IsTask,
- ToolUseID: m.ToolUseID,
- AuthorName: migrateAuthorName,
- AuthorEmail: migrateAuthorEmail,
- }
-}
-
-func tryCompactTranscript(ctx context.Context, transcript []byte, m checkpoint.CommittedMetadata) []byte {
- return compactTranscriptForStartLine(ctx, transcript, m, 0)
-}
-
-func compactTranscriptForStartLine(ctx context.Context, transcript []byte, m checkpoint.CommittedMetadata, startLine int) []byte {
- if len(transcript) == 0 {
- return nil
- }
- if m.Agent == "" {
- logging.Warn(
- ctx, "compact transcript skipped: no agent type in checkpoint metadata",
- slog.String("checkpoint_id", string(m.CheckpointID)),
- )
- return nil
- }
-
- // transcript is read from persisted checkpoint storage and already redacted.
- compacted, err := compact.Compact(redact.AlreadyRedacted(transcript), compact.MetadataFields{
- Agent: string(m.Agent),
- CLIVersion: versioninfo.Version,
- StartLine: startLine,
- })
- if err != nil {
- logging.Warn(
- ctx, "compact transcript generation failed during migration",
- slog.String("checkpoint_id", string(m.CheckpointID)),
- slog.String("agent", string(m.Agent)),
- slog.String("error", err.Error()),
- )
- return nil
- }
- if len(compacted) == 0 {
- logging.Warn(
- ctx, "transcript.jsonl generation produced no output",
- slog.String("checkpoint_id", string(m.CheckpointID)),
- slog.String("agent", string(m.Agent)),
- slog.Int("input_bytes", len(transcript)),
- )
- return nil
- }
- return compacted
-}
-
-// computeCompactOffset determines the transcript.jsonl line offset for a checkpoint
-// by comparing a full compact (startLine=0) against the scoped compact. The difference
-// is the number of compact lines before this checkpoint's data.
-func computeCompactOffset(ctx context.Context, fullTranscript, fullCompact []byte, m checkpoint.CommittedMetadata) int {
- startLine := m.GetTranscriptStart()
- if startLine == 0 || len(fullTranscript) == 0 || m.Agent == "" {
- return 0
- }
-
- if len(fullCompact) == 0 {
- return 0
- }
-
- // fullTranscript is read from persisted checkpoint storage and already redacted.
- scopedCompact, err := compact.Compact(redact.AlreadyRedacted(fullTranscript), compact.MetadataFields{
- Agent: string(m.Agent),
- CLIVersion: versioninfo.Version,
- StartLine: startLine,
- })
- if err != nil {
- logging.Warn(
- ctx, "compact transcript offset calculation failed during migration",
- slog.String("checkpoint_id", string(m.CheckpointID)),
- slog.String("agent", string(m.Agent)),
- slog.String("error", err.Error()),
- )
- return 0
- }
- if len(scopedCompact) == 0 {
- return 0
- }
-
- fullLines := bytes.Count(fullCompact, []byte{'\n'})
- scopedLines := bytes.Count(scopedCompact, []byte{'\n'})
- offset := fullLines - scopedLines
- if offset < 0 {
- logging.Warn(
- ctx, "compact transcript offset was negative during migration, defaulting to 0",
- slog.String("checkpoint_id", string(m.CheckpointID)),
- slog.Int("full_lines", fullLines),
- slog.Int("scoped_lines", scopedLines),
- )
- return 0
- }
- return offset
-}
-
-func collectTaskMetadataForMigratedFullGeneration(repo *git.Repository, cpID id.CheckpointID, summary *checkpoint.CheckpointSummary, v1ToV2SessionIdx map[int]int) (map[int][]plumbing.Hash, error) {
- rootTaskV2SessionIdx, attachRootTasks := latestMigratedV2SessionIndex(v1ToV2SessionIdx)
- return collectTaskMetadataForMigratedFullGenerationWithRootSession(repo, cpID, summary, v1ToV2SessionIdx, rootTaskV2SessionIdx, attachRootTasks)
-}
-
-func collectTaskMetadataForMigratedFullGenerationWithRootSession(
- repo *git.Repository,
- cpID id.CheckpointID,
- summary *checkpoint.CheckpointSummary,
- v1ToV2SessionIdx map[int]int,
- rootTaskV2SessionIdx int,
- attachRootTasks bool,
-) (map[int][]plumbing.Hash, error) {
- v1Tree, err := resolveV1CheckpointTree(repo, cpID)
- if err != nil {
- return nil, err
- }
-
- taskTrees := make(map[int][]plumbing.Hash)
-
- // Legacy v1 layout stores task metadata at checkpoint root: /tasks//...
- // Prefer attaching this tree to the latest session in v2.
- if rootTasksTree, rootTasksErr := v1Tree.Tree("tasks"); rootTasksErr == nil {
- if attachRootTasks {
- taskTrees[rootTaskV2SessionIdx] = append(taskTrees[rootTaskV2SessionIdx], rootTasksTree.Hash)
- }
- }
-
- for sessionIdx := range len(summary.Sessions) {
- sessionDir := strconv.Itoa(sessionIdx)
- sessionTree, sessionErr := v1Tree.Tree(sessionDir)
- if sessionErr != nil {
- continue
- }
-
- tasksTree, tasksErr := sessionTree.Tree("tasks")
- if tasksErr != nil {
- continue
- }
-
- v2SessionIdx, ok := v1ToV2SessionIdx[sessionIdx]
- if !ok {
- continue
- }
- taskTrees[v2SessionIdx] = append(taskTrees[v2SessionIdx], tasksTree.Hash)
- }
-
- return taskTrees, nil
-}
-
-func latestMigratedV2SessionIndex(v1ToV2SessionIdx map[int]int) (int, bool) {
- latest := -1
- for _, v2SessionIdx := range v1ToV2SessionIdx {
- if v2SessionIdx > latest {
- latest = v2SessionIdx
- }
- }
- if latest < 0 {
- return -1, false
- }
- return latest, true
-}
-
-// resolveV1CheckpointTree reads the checkpoint subtree from the v1 branch.
-func resolveV1CheckpointTree(repo *git.Repository, cpID id.CheckpointID) (*object.Tree, error) {
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- // Try remote tracking branch
- remoteRefName := plumbing.NewRemoteReferenceName(migrateRemoteName, paths.MetadataBranchName)
- ref, err = repo.Reference(remoteRefName, true)
- if err != nil {
- return nil, fmt.Errorf("v1 branch not found: %w", err)
- }
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return nil, fmt.Errorf("failed to get v1 commit: %w", err)
- }
-
- rootTree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get v1 tree: %w", err)
- }
-
- cpTree, err := rootTree.Tree(cpID.Path())
- if err != nil {
- return nil, fmt.Errorf("checkpoint %s not found in v1 tree: %w", cpID, err)
- }
-
- return cpTree, nil
-}
-
-// cleanupV1TranscriptFiles removes legacy v1-named transcript files (full.jsonl,
-// full.jsonl.*, content_hash.txt) from /full/current. Older CLI versions wrote
-// these before the rename to raw_transcript; they are inert but waste space.
-// Best-effort: failures are logged and do not block migration.
-func cleanupV1TranscriptFiles(ctx context.Context, _ *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionCount int) {
- if err := v2Store.CleanupV1TranscriptFiles(ctx, cpID, sessionCount); err != nil {
- logging.Warn(
- ctx, "v1 transcript cleanup failed",
- slog.String("checkpoint_id", string(cpID)),
- slog.String("error", err.Error()),
- )
- }
-}
diff --git a/cli/migrate_2.go b/cli/migrate_2.go
new file mode 100644
index 0000000..2009c8c
--- /dev/null
+++ b/cli/migrate_2.go
@@ -0,0 +1,576 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/transcript/compact"
+ "github.com/GrayCodeAI/trace/cli/versioninfo"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func pruneCheckpointFromRoot(repo *git.Repository, rootTreeHash plumbing.Hash, shardPrefix, shardSuffix string) (plumbing.Hash, error) {
+ newRoot, err := checkpoint.UpdateSubtree(
+ repo, rootTreeHash,
+ []string{shardPrefix},
+ nil,
+ checkpoint.UpdateSubtreeOptions{
+ MergeMode: checkpoint.MergeKeepExisting,
+ DeleteNames: []string{shardSuffix},
+ },
+ )
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to prune checkpoint from shard: %w", err)
+ }
+ if newRoot == rootTreeHash {
+ return newRoot, nil
+ }
+
+ newRootTree, err := repo.TreeObject(newRoot)
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to read pruned root tree: %w", err)
+ }
+ shardTree, err := newRootTree.Tree(shardPrefix)
+ if err != nil {
+ return newRoot, nil //nolint:nilerr // The shard prefix was already absent after pruning.
+ }
+ if len(shardTree.Entries) > 0 {
+ return newRoot, nil
+ }
+
+ prunedRoot, err := checkpoint.UpdateSubtree(
+ repo, rootTreeHash,
+ nil,
+ nil,
+ checkpoint.UpdateSubtreeOptions{
+ MergeMode: checkpoint.MergeKeepExisting,
+ DeleteNames: []string{shardPrefix},
+ },
+ )
+ if err != nil {
+ return plumbing.ZeroHash, fmt.Errorf("failed to prune empty shard prefix: %w", err)
+ }
+ return prunedRoot, nil
+}
+
+func collectMissingFullCheckpointForPacking(
+ ctx context.Context,
+ repo *git.Repository,
+ v1Store *checkpoint.GitStore,
+ v2Store *checkpoint.V2GitStore,
+ info checkpoint.CommittedInfo,
+ v2Summary *checkpoint.CheckpointSummary,
+) (*migratedFullCheckpoint, bool, error) {
+ missingSessions, err := collectMissingFullSessionsForPacking(ctx, v2Store, info.CheckpointID, v2Summary)
+ if err != nil {
+ return nil, false, err
+ }
+ if len(missingSessions) == 0 {
+ return nil, false, nil
+ }
+
+ v1Summary, err := v1Store.ReadCommitted(ctx, info.CheckpointID)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to read v1 summary while checking v2 raw artifacts: %w", err)
+ }
+ if v1Summary == nil {
+ return nil, false, fmt.Errorf("v1 checkpoint %s has no summary", info.CheckpointID)
+ }
+
+ v1BySessionID, err := collectV1SessionIndexesForPacking(ctx, v1Store, info.CheckpointID, v1Summary, missingSessions)
+ if err != nil {
+ return nil, false, err
+ }
+
+ fullCheckpoint := &migratedFullCheckpoint{
+ checkpointID: info.CheckpointID,
+ }
+ v1ToV2SessionIdx := make(map[int]int)
+
+ for _, missingSession := range missingSessions {
+ v1Session, ok, readErr := readV1SessionForMissingFullArtifact(ctx, v1Store, info.CheckpointID, v1Summary, v1BySessionID, missingSession)
+ if readErr != nil {
+ return nil, false, readErr
+ }
+ if !ok {
+ return nil, false, fmt.Errorf("failed to find v1 session for v2 session %d while checking raw artifacts", missingSession.sessionIndex)
+ }
+
+ fullCheckpoint.sessions = append(fullCheckpoint.sessions, migratedFullSession{
+ sessionIndex: missingSession.sessionIndex,
+ content: v1Session.content,
+ })
+ v1ToV2SessionIdx[v1Session.sessionIndex] = missingSession.sessionIndex
+ }
+
+ latestV2SessionIdx := len(v2Summary.Sessions) - 1
+ taskTrees, taskErr := collectTaskMetadataForMigratedFullGenerationWithRootSession(
+ repo,
+ info.CheckpointID,
+ v1Summary,
+ v1ToV2SessionIdx,
+ latestV2SessionIdx,
+ latestV2SessionIdx >= 0,
+ )
+ if taskErr != nil {
+ return nil, false, fmt.Errorf("failed to collect task metadata while checking raw artifacts: %w", taskErr)
+ }
+ fullCheckpoint.taskTrees = taskTrees
+
+ return fullCheckpoint, true, nil
+}
+
+type missingFullSessionForPacking struct {
+ sessionIndex int
+ sessionID string
+}
+
+type v1SessionForPacking struct {
+ sessionIndex int
+ content *checkpoint.SessionContent
+}
+
+func collectMissingFullSessionsForPacking(
+ ctx context.Context,
+ v2Store *checkpoint.V2GitStore,
+ checkpointID id.CheckpointID,
+ summary *checkpoint.CheckpointSummary,
+) ([]missingFullSessionForPacking, error) {
+ missingSessions := make([]missingFullSessionForPacking, 0)
+ for sessionIdx := range len(summary.Sessions) {
+ ok, checkErr := hasFullSessionArtifacts(v2Store, checkpointID, sessionIdx)
+ if checkErr != nil {
+ return nil, fmt.Errorf("failed to check v2 session %d artifacts: %w", sessionIdx, checkErr)
+ }
+ if ok {
+ continue
+ }
+
+ v2Content, readErr := v2Store.ReadSessionMetadataAndPrompts(ctx, checkpointID, sessionIdx)
+ if readErr != nil {
+ return nil, fmt.Errorf("failed to read v2 session %d metadata while checking raw artifacts: %w", sessionIdx, readErr)
+ }
+
+ missingSessions = append(missingSessions, missingFullSessionForPacking{
+ sessionIndex: sessionIdx,
+ sessionID: v2Content.Metadata.SessionID,
+ })
+ }
+
+ return missingSessions, nil
+}
+
+func collectV1SessionIndexesForPacking(
+ ctx context.Context,
+ v1Store *checkpoint.GitStore,
+ checkpointID id.CheckpointID,
+ summary *checkpoint.CheckpointSummary,
+ missingSessions []missingFullSessionForPacking,
+) (map[string][]int, error) {
+ neededSessionIDs := make(map[string]struct{})
+ for _, session := range missingSessions {
+ if session.sessionID != "" {
+ neededSessionIDs[session.sessionID] = struct{}{}
+ }
+ }
+
+ bySessionID := make(map[string][]int)
+ if len(neededSessionIDs) == 0 {
+ return bySessionID, nil
+ }
+
+ for sessionIdx := range len(summary.Sessions) {
+ metadata, err := v1Store.ReadSessionMetadata(ctx, checkpointID, sessionIdx)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, fmt.Errorf("context canceled while reading v1 session metadata: %w", ctxErr)
+ }
+ continue
+ }
+ if _, ok := neededSessionIDs[metadata.SessionID]; ok {
+ bySessionID[metadata.SessionID] = append(bySessionID[metadata.SessionID], sessionIdx)
+ }
+ }
+
+ return bySessionID, nil
+}
+
+func readV1SessionForMissingFullArtifact(
+ ctx context.Context,
+ v1Store *checkpoint.GitStore,
+ checkpointID id.CheckpointID,
+ summary *checkpoint.CheckpointSummary,
+ bySessionID map[string][]int,
+ missingSession missingFullSessionForPacking,
+) (v1SessionForPacking, bool, error) {
+ var triedSessionIndexes map[int]struct{}
+ if missingSession.sessionID != "" {
+ indexes := bySessionID[missingSession.sessionID]
+ triedSessionIndexes = make(map[int]struct{}, len(indexes))
+ for i := len(indexes) - 1; i >= 0; i-- {
+ sessionIdx := indexes[i]
+ triedSessionIndexes[sessionIdx] = struct{}{}
+ session, found, err := readV1SessionForPacking(ctx, v1Store, checkpointID, sessionIdx)
+ if err != nil || found {
+ return session, found, err
+ }
+ }
+ }
+
+ if missingSession.sessionIndex >= len(summary.Sessions) {
+ return v1SessionForPacking{}, false, nil
+ }
+ if _, tried := triedSessionIndexes[missingSession.sessionIndex]; tried {
+ return v1SessionForPacking{}, false, nil
+ }
+ return readV1SessionForPacking(ctx, v1Store, checkpointID, missingSession.sessionIndex)
+}
+
+func readV1SessionForPacking(
+ ctx context.Context,
+ v1Store *checkpoint.GitStore,
+ checkpointID id.CheckpointID,
+ sessionIdx int,
+) (v1SessionForPacking, bool, error) {
+ content, err := v1Store.ReadSessionContent(ctx, checkpointID, sessionIdx)
+ if err != nil {
+ if errors.Is(err, checkpoint.ErrNoTranscript) || errors.Is(err, checkpoint.ErrCheckpointNotFound) {
+ return v1SessionForPacking{}, false, nil
+ }
+ return v1SessionForPacking{}, false, fmt.Errorf("failed to read v1 session %d while checking raw artifacts: %w", sessionIdx, err)
+ }
+
+ return v1SessionForPacking{
+ sessionIndex: sessionIdx,
+ content: content,
+ }, true, nil
+}
+
+func hasFullSessionArtifacts(v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) (bool, error) {
+ ok, err := v2Store.HasFullSessionArtifacts(cpID, sessionIdx)
+ if err != nil {
+ return false, fmt.Errorf("failed to check v2 full artifacts for session %d: %w", sessionIdx, err)
+ }
+ return ok, nil
+}
+
+// backfillCompactTranscripts checks sessions in an already-migrated v2 checkpoint
+// for missing transcript.jsonl and attempts to generate + write them from v1 data.
+// Returns errAlreadyMigrated if all sessions already have compact transcripts.
+func backfillCompactTranscripts(ctx context.Context, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, info checkpoint.CommittedInfo, v2Summary *checkpoint.CheckpointSummary) (int, error) {
+ // Find sessions missing transcript.jsonl
+ var needsBackfill []int
+ for i, session := range v2Summary.Sessions {
+ if session.Transcript == "" {
+ needsBackfill = append(needsBackfill, i)
+ }
+ }
+
+ if len(needsBackfill) == 0 {
+ return 0, errAlreadyMigrated
+ }
+
+ backfilled := 0
+ var lastAgent string
+
+ for _, sessionIdx := range needsBackfill {
+ content, readErr := v1Store.ReadSessionContent(ctx, info.CheckpointID, sessionIdx)
+ if readErr != nil {
+ logging.Warn(
+ ctx, "transcript.jsonl backfill: could not read v1 session",
+ slog.String("checkpoint_id", string(info.CheckpointID)),
+ slog.Int("session_index", sessionIdx),
+ slog.String("error", readErr.Error()),
+ )
+ continue
+ }
+
+ if content.Metadata.Agent != "" {
+ lastAgent = string(content.Metadata.Agent)
+ }
+
+ compacted := tryCompactTranscript(ctx, content.Transcript, content.Metadata)
+ if compacted == nil {
+ // tryCompactTranscript already logs for no-agent and compact-error cases;
+ // log the empty-transcript case here.
+ if len(content.Transcript) == 0 {
+ logging.Warn(
+ ctx, "transcript.jsonl backfill: empty transcript in v1",
+ slog.String("checkpoint_id", string(info.CheckpointID)),
+ slog.Int("session_index", sessionIdx),
+ )
+ }
+ continue
+ }
+
+ updateErr := v2Store.UpdateCommitted(ctx, checkpoint.UpdateCommittedOptions{
+ CheckpointID: info.CheckpointID,
+ SessionID: content.Metadata.SessionID,
+ CompactTranscript: compacted,
+ })
+ if updateErr != nil {
+ logging.Warn(
+ ctx, "transcript.jsonl backfill: failed to write to v2",
+ slog.String("checkpoint_id", string(info.CheckpointID)),
+ slog.Int("session_index", sessionIdx),
+ slog.String("error", updateErr.Error()),
+ )
+ continue
+ }
+
+ backfilled++
+ }
+
+ if backfilled == 0 {
+ if lastAgent != "" {
+ return 0, fmt.Errorf("%w: agent %q", errTranscriptNotGeneratable, lastAgent)
+ }
+ return 0, fmt.Errorf("%w: no agent type in metadata", errTranscriptNotGeneratable)
+ }
+
+ return backfilled, nil
+}
+
+func buildMigrateWriteOpts(content *checkpoint.SessionContent, info checkpoint.CommittedInfo, combinedAttribution *checkpoint.InitialAttribution) checkpoint.WriteCommittedOptions {
+ m := content.Metadata
+
+ prompts := checkpoint.SplitPromptContent(content.Prompts)
+
+ return checkpoint.WriteCommittedOptions{
+ CheckpointID: info.CheckpointID,
+ SessionID: m.SessionID,
+ CreatedAt: m.CreatedAt,
+ Strategy: m.Strategy,
+ Branch: m.Branch,
+ // content.Transcript comes from persisted checkpoint storage and is
+ // already redacted.
+ Transcript: redact.AlreadyRedacted(content.Transcript),
+ Prompts: prompts,
+ FilesTouched: m.FilesTouched,
+ CheckpointsCount: m.CheckpointsCount,
+ Agent: m.Agent,
+ Model: m.Model,
+ TurnID: m.TurnID,
+ TokenUsage: m.TokenUsage,
+ SessionMetrics: m.SessionMetrics,
+ InitialAttribution: m.InitialAttribution,
+ PromptAttributionsJSON: m.PromptAttributions,
+ CombinedAttribution: combinedAttribution,
+ Summary: m.Summary,
+ CheckpointTranscriptStart: m.GetTranscriptStart(),
+ TranscriptIdentifierAtStart: m.TranscriptIdentifierAtStart,
+ IsTask: m.IsTask,
+ ToolUseID: m.ToolUseID,
+ AuthorName: migrateAuthorName,
+ AuthorEmail: migrateAuthorEmail,
+ }
+}
+
+func tryCompactTranscript(ctx context.Context, transcript []byte, m checkpoint.CommittedMetadata) []byte {
+ return compactTranscriptForStartLine(ctx, transcript, m, 0)
+}
+
+func compactTranscriptForStartLine(ctx context.Context, transcript []byte, m checkpoint.CommittedMetadata, startLine int) []byte {
+ if len(transcript) == 0 {
+ return nil
+ }
+ if m.Agent == "" {
+ logging.Warn(
+ ctx, "compact transcript skipped: no agent type in checkpoint metadata",
+ slog.String("checkpoint_id", string(m.CheckpointID)),
+ )
+ return nil
+ }
+
+ // transcript is read from persisted checkpoint storage and already redacted.
+ compacted, err := compact.Compact(redact.AlreadyRedacted(transcript), compact.MetadataFields{
+ Agent: string(m.Agent),
+ CLIVersion: versioninfo.Version,
+ StartLine: startLine,
+ })
+ if err != nil {
+ logging.Warn(
+ ctx, "compact transcript generation failed during migration",
+ slog.String("checkpoint_id", string(m.CheckpointID)),
+ slog.String("agent", string(m.Agent)),
+ slog.String("error", err.Error()),
+ )
+ return nil
+ }
+ if len(compacted) == 0 {
+ logging.Warn(
+ ctx, "transcript.jsonl generation produced no output",
+ slog.String("checkpoint_id", string(m.CheckpointID)),
+ slog.String("agent", string(m.Agent)),
+ slog.Int("input_bytes", len(transcript)),
+ )
+ return nil
+ }
+ return compacted
+}
+
+// computeCompactOffset determines the transcript.jsonl line offset for a checkpoint
+// by comparing a full compact (startLine=0) against the scoped compact. The difference
+// is the number of compact lines before this checkpoint's data.
+func computeCompactOffset(ctx context.Context, fullTranscript, fullCompact []byte, m checkpoint.CommittedMetadata) int {
+ startLine := m.GetTranscriptStart()
+ if startLine == 0 || len(fullTranscript) == 0 || m.Agent == "" {
+ return 0
+ }
+
+ if len(fullCompact) == 0 {
+ return 0
+ }
+
+ // fullTranscript is read from persisted checkpoint storage and already redacted.
+ scopedCompact, err := compact.Compact(redact.AlreadyRedacted(fullTranscript), compact.MetadataFields{
+ Agent: string(m.Agent),
+ CLIVersion: versioninfo.Version,
+ StartLine: startLine,
+ })
+ if err != nil {
+ logging.Warn(
+ ctx, "compact transcript offset calculation failed during migration",
+ slog.String("checkpoint_id", string(m.CheckpointID)),
+ slog.String("agent", string(m.Agent)),
+ slog.String("error", err.Error()),
+ )
+ return 0
+ }
+ if len(scopedCompact) == 0 {
+ return 0
+ }
+
+ fullLines := bytes.Count(fullCompact, []byte{'\n'})
+ scopedLines := bytes.Count(scopedCompact, []byte{'\n'})
+ offset := fullLines - scopedLines
+ if offset < 0 {
+ logging.Warn(
+ ctx, "compact transcript offset was negative during migration, defaulting to 0",
+ slog.String("checkpoint_id", string(m.CheckpointID)),
+ slog.Int("full_lines", fullLines),
+ slog.Int("scoped_lines", scopedLines),
+ )
+ return 0
+ }
+ return offset
+}
+
+func collectTaskMetadataForMigratedFullGeneration(repo *git.Repository, cpID id.CheckpointID, summary *checkpoint.CheckpointSummary, v1ToV2SessionIdx map[int]int) (map[int][]plumbing.Hash, error) {
+ rootTaskV2SessionIdx, attachRootTasks := latestMigratedV2SessionIndex(v1ToV2SessionIdx)
+ return collectTaskMetadataForMigratedFullGenerationWithRootSession(repo, cpID, summary, v1ToV2SessionIdx, rootTaskV2SessionIdx, attachRootTasks)
+}
+
+func collectTaskMetadataForMigratedFullGenerationWithRootSession(
+ repo *git.Repository,
+ cpID id.CheckpointID,
+ summary *checkpoint.CheckpointSummary,
+ v1ToV2SessionIdx map[int]int,
+ rootTaskV2SessionIdx int,
+ attachRootTasks bool,
+) (map[int][]plumbing.Hash, error) {
+ v1Tree, err := resolveV1CheckpointTree(repo, cpID)
+ if err != nil {
+ return nil, err
+ }
+
+ taskTrees := make(map[int][]plumbing.Hash)
+
+ // Legacy v1 layout stores task metadata at checkpoint root: /tasks//...
+ // Prefer attaching this tree to the latest session in v2.
+ if rootTasksTree, rootTasksErr := v1Tree.Tree("tasks"); rootTasksErr == nil {
+ if attachRootTasks {
+ taskTrees[rootTaskV2SessionIdx] = append(taskTrees[rootTaskV2SessionIdx], rootTasksTree.Hash)
+ }
+ }
+
+ for sessionIdx := range len(summary.Sessions) {
+ sessionDir := strconv.Itoa(sessionIdx)
+ sessionTree, sessionErr := v1Tree.Tree(sessionDir)
+ if sessionErr != nil {
+ continue
+ }
+
+ tasksTree, tasksErr := sessionTree.Tree("tasks")
+ if tasksErr != nil {
+ continue
+ }
+
+ v2SessionIdx, ok := v1ToV2SessionIdx[sessionIdx]
+ if !ok {
+ continue
+ }
+ taskTrees[v2SessionIdx] = append(taskTrees[v2SessionIdx], tasksTree.Hash)
+ }
+
+ return taskTrees, nil
+}
+
+func latestMigratedV2SessionIndex(v1ToV2SessionIdx map[int]int) (int, bool) {
+ latest := -1
+ for _, v2SessionIdx := range v1ToV2SessionIdx {
+ if v2SessionIdx > latest {
+ latest = v2SessionIdx
+ }
+ }
+ if latest < 0 {
+ return -1, false
+ }
+ return latest, true
+}
+
+// resolveV1CheckpointTree reads the checkpoint subtree from the v1 branch.
+func resolveV1CheckpointTree(repo *git.Repository, cpID id.CheckpointID) (*object.Tree, error) {
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ // Try remote tracking branch
+ remoteRefName := plumbing.NewRemoteReferenceName(migrateRemoteName, paths.MetadataBranchName)
+ ref, err = repo.Reference(remoteRefName, true)
+ if err != nil {
+ return nil, fmt.Errorf("v1 branch not found: %w", err)
+ }
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get v1 commit: %w", err)
+ }
+
+ rootTree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get v1 tree: %w", err)
+ }
+
+ cpTree, err := rootTree.Tree(cpID.Path())
+ if err != nil {
+ return nil, fmt.Errorf("checkpoint %s not found in v1 tree: %w", cpID, err)
+ }
+
+ return cpTree, nil
+}
+
+// cleanupV1TranscriptFiles removes legacy v1-named transcript files (full.jsonl,
+// full.jsonl.*, content_hash.txt) from /full/current. Older CLI versions wrote
+// these before the rename to raw_transcript; they are inert but waste space.
+// Best-effort: failures are logged and do not block migration.
+func cleanupV1TranscriptFiles(ctx context.Context, _ *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionCount int) {
+ if err := v2Store.CleanupV1TranscriptFiles(ctx, cpID, sessionCount); err != nil {
+ logging.Warn(
+ ctx, "v1 transcript cleanup failed",
+ slog.String("checkpoint_id", string(cpID)),
+ slog.String("error", err.Error()),
+ )
+ }
+}
diff --git a/cli/migrate_2_test.go b/cli/migrate_2_test.go
new file mode 100644
index 0000000..902d47d
--- /dev/null
+++ b/cli/migrate_2_test.go
@@ -0,0 +1,780 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/transcript/compact"
+ "github.com/GrayCodeAI/trace/cli/versioninfo"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrateCheckpointsV2_TaskMetadataKeepsFirstConflictingTaskTree(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("8899aabbccdd")
+ toolUseID := "toolu_conflict"
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-conflict",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"conflict\"}\n")),
+ Prompts: []string{"conflict prompt"},
+ IsTask: true,
+ ToolUseID: toolUseID,
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+ addV1RootTasksTreeWithContent(t, repo, cpID, toolUseID, `{"source":"root"}`)
+ addV1SessionTasksTreeWithContent(t, repo, cpID, 0, toolUseID, `{"source":"session"}`)
+
+ var stdout bytes.Buffer
+ result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 1, result.migrated)
+
+ rootTree := v2FullTreeForCheckpoint(t, repo, v2Store, cpID)
+ file, err := rootTree.File(cpID.Path() + "/0/tasks/" + toolUseID + "/checkpoint.json")
+ require.NoError(t, err)
+ content, err := file.Contents()
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"source":"root"}`, content)
+}
+
+func TestMigrateCheckpointsV2_PartialRepairDoesNotMoveRootTaskMetadataToMissingSession(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("99aabbccddee")
+ rootToolUseID := "toolu_root_partial"
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-old",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"old\"}\n")),
+ Prompts: []string{"old prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+ err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-latest",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"latest\"}\n")),
+ Prompts: []string{"latest prompt"},
+ IsTask: true,
+ ToolUseID: rootToolUseID,
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+ addV1RootTasksTreeWithContent(t, repo, cpID, rootToolUseID, `{"source":"root"}`)
+
+ var initialRun bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result1.migrated)
+ assert.True(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "1/tasks/"+rootToolUseID+"/checkpoint.json"))
+
+ removeV2SessionTranscriptFiles(t, repo, v2Store, cpID, 0)
+
+ var rerun bytes.Buffer
+ result2, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result2.migrated)
+ assert.Equal(t, 1, result2.repaired)
+ assert.False(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "0/tasks/"+rootToolUseID+"/checkpoint.json"),
+ "partial repair must not attach root task metadata to the older missing session")
+ assert.True(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "1/tasks/"+rootToolUseID+"/checkpoint.json"),
+ "root task metadata should stay attached to the latest v2 session")
+}
+
+func TestMigrateCheckpointsV2_SkipsCheckpointWhenAllV1SessionsMissingTranscript(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("5566778899bb")
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "metadata-only-session",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(nil),
+ Prompts: []string{"metadata-only prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ var stdout bytes.Buffer
+ result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 0, result.migrated)
+ assert.Equal(t, 1, result.skipped)
+ assert.Equal(t, 0, result.failed)
+ assert.Equal(t, 1, result.missingSessions)
+
+ output := stdout.String()
+ assert.NotContains(t, output, "warning: skipping v1 session 0")
+ assert.NotContains(t, output, "skipped (no migratable v1 sessions")
+
+ summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, readErr)
+ assert.Nil(t, summary)
+}
+
+func TestMigrateCheckpointsV2_ForcePrunesSkippedV2Sessions(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("778899aabbcc")
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-keep",
+ []byte("{\"type\":\"assistant\",\"message\":\"keep\"}\n"),
+ []string{"keep prompt"},
+ )
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-stale",
+ []byte("{\"type\":\"assistant\",\"message\":\"stale\"}\n"),
+ []string{"stale prompt"},
+ )
+
+ var initialRun bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result1.migrated)
+
+ initialSummary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, readErr)
+ require.NotNil(t, initialSummary)
+ require.Len(t, initialSummary.Sessions, 2)
+
+ err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-stale",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(nil),
+ Prompts: []string{"metadata-only stale prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ var stdout bytes.Buffer
+ result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, true)
+ require.NoError(t, rerunErr)
+ assert.Equal(t, 1, result2.migrated)
+ assert.Equal(t, 0, result2.skipped)
+ assert.Equal(t, 1, result2.missingSessions)
+ assert.NotContains(t, stdout.String(), "warning: skipping v1 session 1")
+
+ summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, readErr)
+ require.NotNil(t, summary)
+ require.Len(t, summary.Sessions, 1)
+ assert.Equal(t, "/"+cpID.Path()+"/0/metadata.json", summary.Sessions[0].Metadata)
+
+ _, rootTreeHash, refErr := v2Store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, refErr)
+ rootTree, treeErr := repo.TreeObject(rootTreeHash)
+ require.NoError(t, treeErr)
+ _, err = rootTree.File(cpID.Path() + "/1/" + paths.V2RawTranscriptHashFileName)
+ require.Error(t, err, "force migration should remove stale full transcript data for skipped sessions")
+}
+
+func TestMigrateCheckpointsV2_ForcePruneRemovesEmptyShardWhenAllSessionsSkipped(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("8899aabbccdd")
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-stale-only",
+ []byte("{\"type\":\"assistant\",\"message\":\"stale only\"}\n"),
+ []string{"stale prompt"},
+ )
+
+ var initialRun bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result1.migrated)
+
+ err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-stale-only",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(nil),
+ Prompts: []string{"metadata-only stale prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ var stdout bytes.Buffer
+ result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, true)
+ require.NoError(t, rerunErr)
+ assert.Equal(t, 0, result2.migrated)
+ assert.Equal(t, 1, result2.skipped)
+ assert.Equal(t, 1, result2.missingSessions)
+ assert.NotContains(t, stdout.String(), "no migratable v1 sessions")
+
+ summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, readErr)
+ assert.Nil(t, summary)
+
+ assertNoV2ShardPrefix(t, repo, v2Store, plumbing.ReferenceName(paths.V2MainRefName), cpID)
+ assertNoV2ShardPrefix(t, repo, v2Store, plumbing.ReferenceName(paths.V2FullCurrentRefName), cpID)
+}
+
+func assertNoV2ShardPrefix(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, refName plumbing.ReferenceName, cpID id.CheckpointID) {
+ t.Helper()
+
+ _, rootTreeHash, err := v2Store.GetRefState(refName)
+ require.NoError(t, err)
+
+ rootTree, err := repo.TreeObject(rootTreeHash)
+ require.NoError(t, err)
+
+ _, err = rootTree.Tree(string(cpID[:2]))
+ require.Error(t, err, "force prune should remove an empty shard prefix from %s", refName)
+}
+
+func appendMissingV1SessionReference(t *testing.T, repo *git.Repository, v1Store *checkpoint.GitStore, cpID id.CheckpointID) {
+ t.Helper()
+
+ ctx := context.Background()
+ summary, err := v1Store.ReadCommitted(ctx, cpID)
+ require.NoError(t, err)
+ require.NotNil(t, summary)
+
+ missingIndex := len(summary.Sessions)
+ missingBase := "/" + cpID.Path() + "/" + strconv.Itoa(missingIndex) + "/"
+ summary.Sessions = append(summary.Sessions, checkpoint.SessionFilePaths{
+ Metadata: missingBase + paths.MetadataFileName,
+ Transcript: missingBase + paths.TranscriptFileName,
+ ContentHash: missingBase + paths.ContentHashFileName,
+ Prompt: missingBase + paths.PromptFileName,
+ })
+
+ metadataJSON, err := json.MarshalIndent(summary, "", " ")
+ require.NoError(t, err)
+ metadataJSON = append(metadataJSON, '\n')
+
+ metadataHash, err := checkpoint.CreateBlobFromContent(repo, metadataJSON)
+ require.NoError(t, err)
+
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ ref, err := repo.Reference(refName, true)
+ require.NoError(t, err)
+ commit, err := repo.CommitObject(ref.Hash())
+ require.NoError(t, err)
+
+ newTreeHash, err := checkpoint.UpdateSubtree(
+ repo,
+ commit.TreeHash,
+ []string{string(cpID[:2]), string(cpID[2:])},
+ []object.TreeEntry{{
+ Name: paths.MetadataFileName,
+ Mode: filemode.Regular,
+ Hash: metadataHash,
+ }},
+ checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.MergeKeepExisting},
+ )
+ require.NoError(t, err)
+
+ newCommitHash, err := checkpoint.CreateCommit(ctx, repo, newTreeHash, ref.Hash(), "test: stale v1 session reference\n", "Test", "test@test.com")
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, newCommitHash)))
+}
+
+func TestMigrateCheckpointsV2_NoV1Branch(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+ var stdout bytes.Buffer
+
+ // No v1 data written — ListCommitted returns empty
+ result, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, err)
+ assert.Equal(t, 0, result.migrated)
+ assert.Empty(t, stdout.String())
+}
+
+func TestMigrateCmd_InvalidFlag(t *testing.T) {
+ t.Parallel()
+ cmd := newMigrateCmd()
+ cmd.SetArgs([]string{"--checkpoints", "v3"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported checkpoints version")
+}
+
+func TestMigrateCheckpointsV2_CompactionSkipped(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("e5f6a1b2c3d4")
+ // Write checkpoint with no agent type — compaction will be skipped
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-noagent",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"no agent\"}\n")),
+ Prompts: []string{"compact fail prompt"},
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ var stdout bytes.Buffer
+
+ result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 1, result.migrated)
+ assert.Equal(t, 1, result.compactTranscriptSkipped)
+ assert.Empty(t, stdout.String())
+}
+
+func TestMigrateCheckpointsV2_TaskCheckpoint(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("b2c3d4e5f6a1")
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-task-001",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"task work\"}\n")),
+ Prompts: []string{"task prompt"},
+ IsTask: true,
+ ToolUseID: "toolu_01ABC",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ var stdout bytes.Buffer
+
+ result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 1, result.migrated)
+
+ // Verify task checkpoint exists in v2
+ summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, readErr)
+ require.NotNil(t, summary)
+
+ // Verify task metadata tree was copied into the migrated v2 /full/* generation.
+ rootTree := v2FullTreeForCheckpoint(t, repo, v2Store, cpID)
+ _, taskFileErr := rootTree.File(cpID.Path() + "/0/tasks/toolu_01ABC/checkpoint.json")
+ require.NoError(t, taskFileErr, "expected migrated task checkpoint metadata in /full/*")
+}
+
+func TestMigrateCheckpointsV2_AllSkippedOnRerun(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID1 := id.MustCheckpointID("f6a1b2c3d4e5")
+ cpID2 := id.MustCheckpointID("a1b2c3d4e5f7")
+
+ writeV1Checkpoint(
+ t, v1Store, cpID1, "session-p1",
+ []byte("{\"type\":\"assistant\",\"message\":\"first\"}\n"),
+ []string{"prompt 1"},
+ )
+ writeV1Checkpoint(
+ t, v1Store, cpID2, "session-p2",
+ []byte("{\"type\":\"assistant\",\"message\":\"second\"}\n"),
+ []string{"prompt 2"},
+ )
+
+ // First run: migrates both
+ var discard bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &discard, false)
+ require.NoError(t, err)
+ assert.Equal(t, 2, result1.migrated)
+
+ // Second run: skips both
+ var stdout bytes.Buffer
+ result2, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, err)
+ assert.Equal(t, 0, result2.migrated)
+ assert.Equal(t, 2, result2.skipped)
+}
+
+func TestMigrateCheckpointsV2_BackfillCompactTranscript(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("aabb11223344")
+
+ // Write v1 checkpoint with agent type (so compaction can succeed)
+ err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-backfill",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}\n")),
+ Prompts: []string{"hello"},
+ Agent: "Claude Code",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Write to v2 WITHOUT compact transcript (simulating earlier migration)
+ err = v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-backfill",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n")),
+ Prompts: []string{"hello"},
+ Agent: "Claude Code",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ // CompactTranscript intentionally nil
+ })
+ require.NoError(t, err)
+
+ // Verify no transcript.jsonl on /main yet
+ summary, err := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, err)
+ require.NotNil(t, summary)
+ assert.Empty(t, summary.Sessions[0].Transcript, "should have no compact transcript before backfill")
+
+ // Run migration — should backfill the compact transcript
+ var stdout bytes.Buffer
+ result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 1, result.migrated, "backfill should count as migrated")
+ assert.Equal(t, 0, result.skipped)
+ assert.Equal(t, 1, result.backfilledCompactTranscripts)
+ assert.Empty(t, stdout.String())
+
+ // Verify transcript.jsonl now exists
+ summary2, err := v2Store.ReadCommitted(context.Background(), cpID)
+ require.NoError(t, err)
+ require.NotNil(t, summary2)
+ assert.NotEmpty(t, summary2.Sessions[0].Transcript, "should have compact transcript after backfill")
+}
+
+func TestMigrateCheckpointsV2_UsesComputedCompactTranscriptStart(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("5566778899aa")
+ transcript := []byte(
+ "{\"type\":\"human\",\"message\":{\"content\":\"prompt 1\"}}\n" +
+ "{\"type\":\"assistant\",\"message\":{\"content\":\"reply 1\"}}\n" +
+ "{\"type\":\"human\",\"message\":{\"content\":\"prompt 2\"}}\n" +
+ "{\"type\":\"assistant\",\"message\":{\"content\":\"reply 2\"}}\n",
+ )
+ err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-compact-start-migrate",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted(transcript),
+ Prompts: []string{"prompt 2"},
+ Agent: agent.AgentTypeClaudeCode,
+ CheckpointTranscriptStart: 2, // full transcript line domain
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ v1Content, err := v1Store.ReadSessionContent(ctx, cpID, 0)
+ require.NoError(t, err)
+ fullCompacted := tryCompactTranscript(ctx, v1Content.Transcript, v1Content.Metadata)
+ require.NotNil(t, fullCompacted)
+ scopedCompacted, err := compact.Compact(redact.AlreadyRedacted(v1Content.Transcript), compact.MetadataFields{
+ Agent: string(v1Content.Metadata.Agent),
+ CLIVersion: versioninfo.Version,
+ StartLine: v1Content.Metadata.GetTranscriptStart(),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, scopedCompacted)
+ require.Greater(t, bytes.Count(fullCompacted, []byte{'\n'}), bytes.Count(scopedCompacted, []byte{'\n'}))
+ expectedOffset := computeCompactOffset(ctx, v1Content.Transcript, fullCompacted, v1Content.Metadata)
+ require.Positive(t, expectedOffset, "expected non-zero compact transcript start")
+
+ var stdout bytes.Buffer
+ result, migrateErr := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, migrateErr)
+ assert.Equal(t, 1, result.migrated)
+
+ v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
+ require.NoError(t, err)
+ v2MainTree, err := v2MainCommit.Tree()
+ require.NoError(t, err)
+
+ metadataFile, err := v2MainTree.File(cpID.Path() + "/0/" + paths.MetadataFileName)
+ require.NoError(t, err)
+ metadataContent, err := metadataFile.Contents()
+ require.NoError(t, err)
+
+ var metadata checkpoint.CommittedMetadata
+ require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata))
+ assert.Equal(t, expectedOffset, metadata.CheckpointTranscriptStart)
+
+ storedCompact, err := v2Store.ReadSessionCompactTranscript(ctx, cpID, 0)
+ require.NoError(t, err)
+ assert.Equal(t, fullCompacted, storedCompact, "migration should persist cumulative compact transcript")
+}
+
+func TestMigrateCheckpointsV2_RepairsMissingFullTranscriptBeforeBackfill(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("112233aabbcc")
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-repair-001",
+ []byte("{\"type\":\"assistant\",\"message\":\"repair me\"}\n"),
+ []string{"repair prompt"},
+ )
+
+ // Initial migration to create v2 state.
+ var initialRun bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result1.migrated)
+
+ // Simulate interrupted migration by removing raw transcript files from every /full/* ref.
+ removeV2SessionTranscriptFiles(t, repo, v2Store, cpID, 0)
+
+ // Re-run migration: should requeue the missing raw transcript for final
+ // generation packing and count as migrated (not skipped).
+ var rerun bytes.Buffer
+ result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
+ require.NoError(t, rerunErr)
+ assert.Equal(t, 1, result2.migrated)
+ assert.Equal(t, 0, result2.failed)
+ assert.Equal(t, 1, result2.repaired)
+ assert.Empty(t, rerun.String())
+
+ content, readErr := v2Store.ReadSessionContent(context.Background(), cpID, 0)
+ require.NoError(t, readErr)
+ assert.NotEmpty(t, content.Transcript, "raw full transcript should be restored in a packed /full/* generation")
+ assert.False(t, hasCurrentFullSessionArtifactsForTest(t, repo, v2Store, cpID, 0),
+ "rerun repair must not rehydrate migrated raw transcripts into /full/current")
+}
+
+func TestMigrateCheckpointsV2_SkipsRepairWhenArchivedFullExists(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+
+ cpID := id.MustCheckpointID("334455ddeeff")
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-repair-archive-001",
+ []byte("{\"type\":\"assistant\",\"message\":\"repair from archive fallback\"}\n"),
+ []string{"repair archive prompt"},
+ )
+
+ // Initial migration to seed v2.
+ var initialRun bytes.Buffer
+ result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result1.migrated)
+
+ // Fresh migration packs raw transcripts into an archived generation and
+ // leaves /full/current empty.
+ archivedRead, archivedReadErr := v2Store.ReadSessionContent(context.Background(), cpID, 0)
+ require.NoError(t, archivedReadErr)
+ assert.NotEmpty(t, archivedRead.Transcript)
+
+ // Re-run migration: archived /full/* artifacts are sufficient, so it should
+ // not rehydrate old raw transcripts into /full/current.
+ var rerun bytes.Buffer
+ result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
+ require.NoError(t, rerunErr)
+ assert.Equal(t, 0, result2.migrated)
+ assert.Equal(t, 1, result2.skipped)
+ assert.NotContains(t, rerun.String(), "repaired partial v2 checkpoint state")
+
+ ok, checkErr := hasFullSessionArtifacts(v2Store, cpID, 0)
+ require.NoError(t, checkErr)
+ assert.True(t, ok, "expected archived /full/* artifacts to count as present")
+ assert.False(t, hasCurrentFullSessionArtifactsForTest(t, repo, v2Store, cpID, 0),
+ "migration rerun must not copy archived artifacts back into /full/current")
+}
+
+func removeV2SessionTranscriptFiles(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) {
+ t.Helper()
+
+ for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
+ removeV2SessionTranscriptFilesFromRef(t, repo, v2Store, refName, cpID, sessionIdx)
+ }
+}
+
+func removeV2SessionTranscriptFilesFromRef(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, refName plumbing.ReferenceName, cpID id.CheckpointID, sessionIdx int) {
+ t.Helper()
+
+ parentHash, rootTreeHash, err := v2Store.GetRefState(refName)
+ if err != nil {
+ return
+ }
+
+ newRootHash, updateErr := checkpoint.UpdateSubtree(
+ repo,
+ rootTreeHash,
+ []string{string(cpID[:2]), string(cpID[2:]), strconv.Itoa(sessionIdx)},
+ nil,
+ checkpoint.UpdateSubtreeOptions{
+ MergeMode: checkpoint.MergeKeepExisting,
+ DeleteNames: []string{
+ paths.V2RawTranscriptFileName,
+ paths.V2RawTranscriptFileName + ".001",
+ paths.V2RawTranscriptFileName + ".002",
+ paths.V2RawTranscriptHashFileName,
+ },
+ },
+ )
+ require.NoError(t, updateErr)
+ if newRootHash == rootTreeHash {
+ return
+ }
+
+ commitHash, commitErr := checkpoint.CreateCommit(context.Background(), repo, newRootHash, parentHash, "test: remove full transcript\n", "Test", "test@test.com")
+ require.NoError(t, commitErr)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
+}
+
+func v2FullTreeForCheckpoint(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID) *object.Tree {
+ t.Helper()
+
+ for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
+ _, rootTreeHash, err := v2Store.GetRefState(refName)
+ if err != nil {
+ continue
+ }
+ rootTree, err := repo.TreeObject(rootTreeHash)
+ require.NoError(t, err)
+ if _, treeErr := rootTree.Tree(cpID.Path()); treeErr == nil {
+ return rootTree
+ }
+ }
+
+ t.Fatalf("checkpoint %s not found in any v2 /full/* ref", cpID)
+ return nil
+}
+
+func v2FullFileExistsForCheckpoint(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, relPath string) bool {
+ t.Helper()
+
+ for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
+ _, rootTreeHash, err := v2Store.GetRefState(refName)
+ if err != nil {
+ continue
+ }
+ rootTree, err := repo.TreeObject(rootTreeHash)
+ require.NoError(t, err)
+ if _, err := rootTree.File(cpID.Path() + "/" + relPath); err == nil {
+ return true
+ }
+ }
+
+ return false
+}
+
+func v2FullRefSearchOrderForTest(t *testing.T, v2Store *checkpoint.V2GitStore) []plumbing.ReferenceName {
+ t.Helper()
+
+ refNames := []plumbing.ReferenceName{plumbing.ReferenceName(paths.V2FullCurrentRefName)}
+ archived, err := v2Store.ListArchivedGenerations()
+ require.NoError(t, err)
+ for i := len(archived) - 1; i >= 0; i-- {
+ refNames = append(refNames, plumbing.ReferenceName(paths.V2FullRefPrefix+archived[i]))
+ }
+ return refNames
+}
+
+func hasCurrentFullSessionArtifactsForTest(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) bool {
+ t.Helper()
+
+ _, rootTreeHash, err := v2Store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
+ require.NoError(t, err)
+
+ rootTree, err := repo.TreeObject(rootTreeHash)
+ require.NoError(t, err)
+
+ sessionPath := cpID.Path() + "/" + strconv.Itoa(sessionIdx)
+ sessionTree, err := rootTree.Tree(sessionPath)
+ if err != nil {
+ return false
+ }
+
+ hasTranscript := false
+ for _, entry := range sessionTree.Entries {
+ if entry.Name == paths.V2RawTranscriptFileName || strings.HasPrefix(entry.Name, paths.V2RawTranscriptFileName+".") {
+ hasTranscript = true
+ break
+ }
+ }
+ if !hasTranscript {
+ return false
+ }
+
+ _, err = sessionTree.File(paths.V2RawTranscriptHashFileName)
+ return err == nil
+}
+
+func TestBuildMigrateWriteOpts_PromptSeparatorRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ cpID := id.MustCheckpointID("123456abcdef")
+ rawPrompts := strings.Join([]string{
+ "first line\nwith newline",
+ "second prompt",
+ }, checkpoint.PromptSeparator)
+
+ opts := buildMigrateWriteOpts(&checkpoint.SessionContent{
+ Metadata: checkpoint.CommittedMetadata{
+ SessionID: "session-prompts-001",
+ Strategy: "manual-commit",
+ },
+ Prompts: rawPrompts,
+ }, checkpoint.CommittedInfo{
+ CheckpointID: cpID,
+ }, nil)
+
+ require.Len(t, opts.Prompts, 2)
+ assert.Equal(t, "first line\nwith newline", opts.Prompts[0])
+ assert.Equal(t, "second prompt", opts.Prompts[1])
+}
+
+func TestLatestMigratedV2SessionIndex_Empty(t *testing.T) {
+ t.Parallel()
+
+ latest, ok := latestMigratedV2SessionIndex(nil)
+ assert.Equal(t, -1, latest)
+ assert.False(t, ok)
+}
diff --git a/cli/migrate_3_test.go b/cli/migrate_3_test.go
new file mode 100644
index 0000000..451459a
--- /dev/null
+++ b/cli/migrate_3_test.go
@@ -0,0 +1,216 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrateCheckpointsV2_PreservesPromptAttributions(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("aabb22334455")
+ promptAttrs := json.RawMessage(`[{"prompt_index":0,"user_lines":["main.go:10"]}]`)
+
+ err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: "session-pa-001",
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"pa test\"}\n")),
+ Prompts: []string{"test prompt"},
+ PromptAttributionsJSON: promptAttrs,
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Verify v1 has prompt_attributions
+ v1Content, err := v1Store.ReadSessionContent(ctx, cpID, 0)
+ require.NoError(t, err)
+ require.NotNil(t, v1Content.Metadata.PromptAttributions, "v1 should have prompt_attributions")
+
+ // Migrate
+ var stdout bytes.Buffer
+ result, err := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.migrated)
+
+ // Read v2 session metadata from /main ref and verify prompt_attributions preserved
+ v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
+ require.NoError(t, err)
+ v2MainTree, err := v2MainCommit.Tree()
+ require.NoError(t, err)
+
+ metadataFile, err := v2MainTree.File(cpID.Path() + "/0/" + paths.MetadataFileName)
+ require.NoError(t, err)
+ metadataContent, err := metadataFile.Contents()
+ require.NoError(t, err)
+
+ var metadata checkpoint.CommittedMetadata
+ require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata))
+ assert.JSONEq(t, string(promptAttrs), string(metadata.PromptAttributions),
+ "v2 session metadata should preserve prompt_attributions from v1")
+}
+
+func TestMigrateCheckpointsV2_PreservesCombinedAttribution(t *testing.T) {
+ t.Parallel()
+ repo := initMigrateTestRepo(t)
+ v1Store, v2Store := newMigrateStores(repo)
+ ctx := context.Background()
+
+ cpID := id.MustCheckpointID("ccdd55667788")
+
+ // Write two sessions so combined attribution is meaningful
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-ca-001",
+ []byte("{\"type\":\"assistant\",\"message\":\"session 1\"}\n"),
+ []string{"prompt 1"},
+ )
+ writeV1Checkpoint(
+ t, v1Store, cpID, "session-ca-002",
+ []byte("{\"type\":\"assistant\",\"message\":\"session 2\"}\n"),
+ []string{"prompt 2"},
+ )
+
+ // Inject CombinedAttribution into v1 root summary
+ combined := &checkpoint.InitialAttribution{
+ CalculatedAt: time.Date(2026, 4, 15, 0, 18, 47, 0, time.UTC),
+ AgentLines: 119,
+ AgentRemoved: 94,
+ HumanAdded: 3,
+ HumanModified: 0,
+ HumanRemoved: 1,
+ TotalCommitted: 122,
+ TotalLinesChanged: 217,
+ AgentPercentage: 98.15668202764977,
+ MetricVersion: 2,
+ }
+ err := v1Store.UpdateCheckpointSummary(ctx, cpID, combined)
+ require.NoError(t, err)
+
+ // Verify v1 root summary has CombinedAttribution
+ v1Summary, err := v1Store.ReadCommitted(ctx, cpID)
+ require.NoError(t, err)
+ require.NotNil(t, v1Summary.CombinedAttribution, "v1 should have combined_attribution")
+
+ // Migrate
+ var stdout bytes.Buffer
+ result, err := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.migrated)
+
+ // Read v2 root summary and verify CombinedAttribution preserved
+ v2Summary, err := v2Store.ReadCommitted(ctx, cpID)
+ require.NoError(t, err)
+ require.NotNil(t, v2Summary)
+ require.NotNil(t, v2Summary.CombinedAttribution,
+ "v2 root summary should preserve combined_attribution from v1")
+ assert.Equal(t, combined.CalculatedAt, v2Summary.CombinedAttribution.CalculatedAt)
+ assert.Equal(t, combined.AgentLines, v2Summary.CombinedAttribution.AgentLines)
+ assert.Equal(t, combined.AgentRemoved, v2Summary.CombinedAttribution.AgentRemoved)
+ assert.Equal(t, combined.HumanAdded, v2Summary.CombinedAttribution.HumanAdded)
+ assert.Equal(t, combined.HumanModified, v2Summary.CombinedAttribution.HumanModified)
+ assert.Equal(t, combined.HumanRemoved, v2Summary.CombinedAttribution.HumanRemoved)
+ assert.Equal(t, combined.TotalCommitted, v2Summary.CombinedAttribution.TotalCommitted)
+ assert.Equal(t, combined.TotalLinesChanged, v2Summary.CombinedAttribution.TotalLinesChanged)
+ assert.InDelta(t, combined.AgentPercentage, v2Summary.CombinedAttribution.AgentPercentage, 0.001)
+ assert.Equal(t, combined.MetricVersion, v2Summary.CombinedAttribution.MetricVersion)
+}
+
+func TestSortMigratableCheckpoints(t *testing.T) {
+ t.Parallel()
+
+ t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ t2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
+ t3 := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ input []checkpoint.CommittedInfo
+ want []id.CheckpointID
+ }{
+ {
+ name: "chronological order",
+ input: []checkpoint.CommittedInfo{
+ {CheckpointID: id.MustCheckpointID("000000000003"), CreatedAt: t3},
+ {CheckpointID: id.MustCheckpointID("000000000001"), CreatedAt: t1},
+ {CheckpointID: id.MustCheckpointID("000000000002"), CreatedAt: t2},
+ },
+ want: []id.CheckpointID{
+ id.MustCheckpointID("000000000001"),
+ id.MustCheckpointID("000000000002"),
+ id.MustCheckpointID("000000000003"),
+ },
+ },
+ {
+ name: "ties on CreatedAt break by checkpoint ID",
+ input: []checkpoint.CommittedInfo{
+ {CheckpointID: id.MustCheckpointID("0000000000bb"), CreatedAt: t1},
+ {CheckpointID: id.MustCheckpointID("0000000000aa"), CreatedAt: t1},
+ {CheckpointID: id.MustCheckpointID("0000000000cc"), CreatedAt: t1},
+ },
+ want: []id.CheckpointID{
+ id.MustCheckpointID("0000000000aa"),
+ id.MustCheckpointID("0000000000bb"),
+ id.MustCheckpointID("0000000000cc"),
+ },
+ },
+ {
+ name: "zero CreatedAt sorts after non-zero, ties by ID",
+ input: []checkpoint.CommittedInfo{
+ {CheckpointID: id.MustCheckpointID("0000000000aa")},
+ {CheckpointID: id.MustCheckpointID("000000000002"), CreatedAt: t2},
+ {CheckpointID: id.MustCheckpointID("0000000000bb")},
+ {CheckpointID: id.MustCheckpointID("000000000001"), CreatedAt: t1},
+ },
+ want: []id.CheckpointID{
+ id.MustCheckpointID("000000000001"),
+ id.MustCheckpointID("000000000002"),
+ id.MustCheckpointID("0000000000aa"),
+ id.MustCheckpointID("0000000000bb"),
+ },
+ },
+ {
+ name: "all-zero CreatedAt sorts by ID",
+ input: []checkpoint.CommittedInfo{
+ {CheckpointID: id.MustCheckpointID("0000000000cc")},
+ {CheckpointID: id.MustCheckpointID("0000000000aa")},
+ {CheckpointID: id.MustCheckpointID("0000000000bb")},
+ },
+ want: []id.CheckpointID{
+ id.MustCheckpointID("0000000000aa"),
+ id.MustCheckpointID("0000000000bb"),
+ id.MustCheckpointID("0000000000cc"),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ input := make([]checkpoint.CommittedInfo, len(tt.input))
+ copy(input, tt.input)
+ sortMigratableCheckpoints(input)
+ got := make([]id.CheckpointID, len(input))
+ for i, c := range input {
+ got[i] = c.CheckpointID
+ }
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/cli/migrate_test.go b/cli/migrate_test.go
index f867621..7d0ca3b 100644
--- a/cli/migrate_test.go
+++ b/cli/migrate_test.go
@@ -3,19 +3,14 @@ package cli
import (
"bytes"
"context"
- "encoding/json"
"strconv"
- "strings"
"testing"
"time"
- "github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/testutil"
- "github.com/GrayCodeAI/trace/cli/transcript/compact"
- "github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
@@ -790,958 +785,3 @@ func TestMigrateCheckpointsV2_TaskMetadataUsesMigratedSessionIndexAfterSkip(t *t
_, err = rootTree.File(cpID.Path() + "/2/tasks/toolu_root_shifted/checkpoint.json")
require.Error(t, err, "task metadata must not be written under a non-existent v2 session")
}
-
-func TestMigrateCheckpointsV2_TaskMetadataKeepsFirstConflictingTaskTree(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("8899aabbccdd")
- toolUseID := "toolu_conflict"
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-conflict",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"conflict\"}\n")),
- Prompts: []string{"conflict prompt"},
- IsTask: true,
- ToolUseID: toolUseID,
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
- addV1RootTasksTreeWithContent(t, repo, cpID, toolUseID, `{"source":"root"}`)
- addV1SessionTasksTreeWithContent(t, repo, cpID, 0, toolUseID, `{"source":"session"}`)
-
- var stdout bytes.Buffer
- result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 1, result.migrated)
-
- rootTree := v2FullTreeForCheckpoint(t, repo, v2Store, cpID)
- file, err := rootTree.File(cpID.Path() + "/0/tasks/" + toolUseID + "/checkpoint.json")
- require.NoError(t, err)
- content, err := file.Contents()
- require.NoError(t, err)
- assert.JSONEq(t, `{"source":"root"}`, content)
-}
-
-func TestMigrateCheckpointsV2_PartialRepairDoesNotMoveRootTaskMetadataToMissingSession(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("99aabbccddee")
- rootToolUseID := "toolu_root_partial"
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-old",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"old\"}\n")),
- Prompts: []string{"old prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
- err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-latest",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"latest\"}\n")),
- Prompts: []string{"latest prompt"},
- IsTask: true,
- ToolUseID: rootToolUseID,
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
- addV1RootTasksTreeWithContent(t, repo, cpID, rootToolUseID, `{"source":"root"}`)
-
- var initialRun bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result1.migrated)
- assert.True(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "1/tasks/"+rootToolUseID+"/checkpoint.json"))
-
- removeV2SessionTranscriptFiles(t, repo, v2Store, cpID, 0)
-
- var rerun bytes.Buffer
- result2, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result2.migrated)
- assert.Equal(t, 1, result2.repaired)
- assert.False(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "0/tasks/"+rootToolUseID+"/checkpoint.json"),
- "partial repair must not attach root task metadata to the older missing session")
- assert.True(t, v2FullFileExistsForCheckpoint(t, repo, v2Store, cpID, "1/tasks/"+rootToolUseID+"/checkpoint.json"),
- "root task metadata should stay attached to the latest v2 session")
-}
-
-func TestMigrateCheckpointsV2_SkipsCheckpointWhenAllV1SessionsMissingTranscript(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("5566778899bb")
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "metadata-only-session",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(nil),
- Prompts: []string{"metadata-only prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- var stdout bytes.Buffer
- result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 0, result.migrated)
- assert.Equal(t, 1, result.skipped)
- assert.Equal(t, 0, result.failed)
- assert.Equal(t, 1, result.missingSessions)
-
- output := stdout.String()
- assert.NotContains(t, output, "warning: skipping v1 session 0")
- assert.NotContains(t, output, "skipped (no migratable v1 sessions")
-
- summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, readErr)
- assert.Nil(t, summary)
-}
-
-func TestMigrateCheckpointsV2_ForcePrunesSkippedV2Sessions(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("778899aabbcc")
- writeV1Checkpoint(
- t, v1Store, cpID, "session-keep",
- []byte("{\"type\":\"assistant\",\"message\":\"keep\"}\n"),
- []string{"keep prompt"},
- )
- writeV1Checkpoint(
- t, v1Store, cpID, "session-stale",
- []byte("{\"type\":\"assistant\",\"message\":\"stale\"}\n"),
- []string{"stale prompt"},
- )
-
- var initialRun bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result1.migrated)
-
- initialSummary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, readErr)
- require.NotNil(t, initialSummary)
- require.Len(t, initialSummary.Sessions, 2)
-
- err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-stale",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(nil),
- Prompts: []string{"metadata-only stale prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- var stdout bytes.Buffer
- result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, true)
- require.NoError(t, rerunErr)
- assert.Equal(t, 1, result2.migrated)
- assert.Equal(t, 0, result2.skipped)
- assert.Equal(t, 1, result2.missingSessions)
- assert.NotContains(t, stdout.String(), "warning: skipping v1 session 1")
-
- summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, readErr)
- require.NotNil(t, summary)
- require.Len(t, summary.Sessions, 1)
- assert.Equal(t, "/"+cpID.Path()+"/0/metadata.json", summary.Sessions[0].Metadata)
-
- _, rootTreeHash, refErr := v2Store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, refErr)
- rootTree, treeErr := repo.TreeObject(rootTreeHash)
- require.NoError(t, treeErr)
- _, err = rootTree.File(cpID.Path() + "/1/" + paths.V2RawTranscriptHashFileName)
- require.Error(t, err, "force migration should remove stale full transcript data for skipped sessions")
-}
-
-func TestMigrateCheckpointsV2_ForcePruneRemovesEmptyShardWhenAllSessionsSkipped(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("8899aabbccdd")
- writeV1Checkpoint(
- t, v1Store, cpID, "session-stale-only",
- []byte("{\"type\":\"assistant\",\"message\":\"stale only\"}\n"),
- []string{"stale prompt"},
- )
-
- var initialRun bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result1.migrated)
-
- err = v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-stale-only",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(nil),
- Prompts: []string{"metadata-only stale prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- var stdout bytes.Buffer
- result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, true)
- require.NoError(t, rerunErr)
- assert.Equal(t, 0, result2.migrated)
- assert.Equal(t, 1, result2.skipped)
- assert.Equal(t, 1, result2.missingSessions)
- assert.NotContains(t, stdout.String(), "no migratable v1 sessions")
-
- summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, readErr)
- assert.Nil(t, summary)
-
- assertNoV2ShardPrefix(t, repo, v2Store, plumbing.ReferenceName(paths.V2MainRefName), cpID)
- assertNoV2ShardPrefix(t, repo, v2Store, plumbing.ReferenceName(paths.V2FullCurrentRefName), cpID)
-}
-
-func assertNoV2ShardPrefix(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, refName plumbing.ReferenceName, cpID id.CheckpointID) {
- t.Helper()
-
- _, rootTreeHash, err := v2Store.GetRefState(refName)
- require.NoError(t, err)
-
- rootTree, err := repo.TreeObject(rootTreeHash)
- require.NoError(t, err)
-
- _, err = rootTree.Tree(string(cpID[:2]))
- require.Error(t, err, "force prune should remove an empty shard prefix from %s", refName)
-}
-
-func appendMissingV1SessionReference(t *testing.T, repo *git.Repository, v1Store *checkpoint.GitStore, cpID id.CheckpointID) {
- t.Helper()
-
- ctx := context.Background()
- summary, err := v1Store.ReadCommitted(ctx, cpID)
- require.NoError(t, err)
- require.NotNil(t, summary)
-
- missingIndex := len(summary.Sessions)
- missingBase := "/" + cpID.Path() + "/" + strconv.Itoa(missingIndex) + "/"
- summary.Sessions = append(summary.Sessions, checkpoint.SessionFilePaths{
- Metadata: missingBase + paths.MetadataFileName,
- Transcript: missingBase + paths.TranscriptFileName,
- ContentHash: missingBase + paths.ContentHashFileName,
- Prompt: missingBase + paths.PromptFileName,
- })
-
- metadataJSON, err := json.MarshalIndent(summary, "", " ")
- require.NoError(t, err)
- metadataJSON = append(metadataJSON, '\n')
-
- metadataHash, err := checkpoint.CreateBlobFromContent(repo, metadataJSON)
- require.NoError(t, err)
-
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- ref, err := repo.Reference(refName, true)
- require.NoError(t, err)
- commit, err := repo.CommitObject(ref.Hash())
- require.NoError(t, err)
-
- newTreeHash, err := checkpoint.UpdateSubtree(
- repo,
- commit.TreeHash,
- []string{string(cpID[:2]), string(cpID[2:])},
- []object.TreeEntry{{
- Name: paths.MetadataFileName,
- Mode: filemode.Regular,
- Hash: metadataHash,
- }},
- checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.MergeKeepExisting},
- )
- require.NoError(t, err)
-
- newCommitHash, err := checkpoint.CreateCommit(ctx, repo, newTreeHash, ref.Hash(), "test: stale v1 session reference\n", "Test", "test@test.com")
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, newCommitHash)))
-}
-
-func TestMigrateCheckpointsV2_NoV1Branch(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
- var stdout bytes.Buffer
-
- // No v1 data written — ListCommitted returns empty
- result, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, err)
- assert.Equal(t, 0, result.migrated)
- assert.Empty(t, stdout.String())
-}
-
-func TestMigrateCmd_InvalidFlag(t *testing.T) {
- t.Parallel()
- cmd := newMigrateCmd()
- cmd.SetArgs([]string{"--checkpoints", "v3"})
-
- err := cmd.Execute()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "unsupported checkpoints version")
-}
-
-func TestMigrateCheckpointsV2_CompactionSkipped(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("e5f6a1b2c3d4")
- // Write checkpoint with no agent type — compaction will be skipped
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-noagent",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"no agent\"}\n")),
- Prompts: []string{"compact fail prompt"},
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- var stdout bytes.Buffer
-
- result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 1, result.migrated)
- assert.Equal(t, 1, result.compactTranscriptSkipped)
- assert.Empty(t, stdout.String())
-}
-
-func TestMigrateCheckpointsV2_TaskCheckpoint(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("b2c3d4e5f6a1")
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-task-001",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"task work\"}\n")),
- Prompts: []string{"task prompt"},
- IsTask: true,
- ToolUseID: "toolu_01ABC",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- var stdout bytes.Buffer
-
- result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 1, result.migrated)
-
- // Verify task checkpoint exists in v2
- summary, readErr := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, readErr)
- require.NotNil(t, summary)
-
- // Verify task metadata tree was copied into the migrated v2 /full/* generation.
- rootTree := v2FullTreeForCheckpoint(t, repo, v2Store, cpID)
- _, taskFileErr := rootTree.File(cpID.Path() + "/0/tasks/toolu_01ABC/checkpoint.json")
- require.NoError(t, taskFileErr, "expected migrated task checkpoint metadata in /full/*")
-}
-
-func TestMigrateCheckpointsV2_AllSkippedOnRerun(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID1 := id.MustCheckpointID("f6a1b2c3d4e5")
- cpID2 := id.MustCheckpointID("a1b2c3d4e5f7")
-
- writeV1Checkpoint(
- t, v1Store, cpID1, "session-p1",
- []byte("{\"type\":\"assistant\",\"message\":\"first\"}\n"),
- []string{"prompt 1"},
- )
- writeV1Checkpoint(
- t, v1Store, cpID2, "session-p2",
- []byte("{\"type\":\"assistant\",\"message\":\"second\"}\n"),
- []string{"prompt 2"},
- )
-
- // First run: migrates both
- var discard bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &discard, false)
- require.NoError(t, err)
- assert.Equal(t, 2, result1.migrated)
-
- // Second run: skips both
- var stdout bytes.Buffer
- result2, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, err)
- assert.Equal(t, 0, result2.migrated)
- assert.Equal(t, 2, result2.skipped)
-}
-
-func TestMigrateCheckpointsV2_BackfillCompactTranscript(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("aabb11223344")
-
- // Write v1 checkpoint with agent type (so compaction can succeed)
- err := v1Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-backfill",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}\n")),
- Prompts: []string{"hello"},
- Agent: "Claude Code",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Write to v2 WITHOUT compact transcript (simulating earlier migration)
- err = v2Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-backfill",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n")),
- Prompts: []string{"hello"},
- Agent: "Claude Code",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- // CompactTranscript intentionally nil
- })
- require.NoError(t, err)
-
- // Verify no transcript.jsonl on /main yet
- summary, err := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, err)
- require.NotNil(t, summary)
- assert.Empty(t, summary.Sessions[0].Transcript, "should have no compact transcript before backfill")
-
- // Run migration — should backfill the compact transcript
- var stdout bytes.Buffer
- result, migrateErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 1, result.migrated, "backfill should count as migrated")
- assert.Equal(t, 0, result.skipped)
- assert.Equal(t, 1, result.backfilledCompactTranscripts)
- assert.Empty(t, stdout.String())
-
- // Verify transcript.jsonl now exists
- summary2, err := v2Store.ReadCommitted(context.Background(), cpID)
- require.NoError(t, err)
- require.NotNil(t, summary2)
- assert.NotEmpty(t, summary2.Sessions[0].Transcript, "should have compact transcript after backfill")
-}
-
-func TestMigrateCheckpointsV2_UsesComputedCompactTranscriptStart(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("5566778899aa")
- transcript := []byte(
- "{\"type\":\"human\",\"message\":{\"content\":\"prompt 1\"}}\n" +
- "{\"type\":\"assistant\",\"message\":{\"content\":\"reply 1\"}}\n" +
- "{\"type\":\"human\",\"message\":{\"content\":\"prompt 2\"}}\n" +
- "{\"type\":\"assistant\",\"message\":{\"content\":\"reply 2\"}}\n",
- )
- err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-compact-start-migrate",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted(transcript),
- Prompts: []string{"prompt 2"},
- Agent: agent.AgentTypeClaudeCode,
- CheckpointTranscriptStart: 2, // full transcript line domain
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- v1Content, err := v1Store.ReadSessionContent(ctx, cpID, 0)
- require.NoError(t, err)
- fullCompacted := tryCompactTranscript(ctx, v1Content.Transcript, v1Content.Metadata)
- require.NotNil(t, fullCompacted)
- scopedCompacted, err := compact.Compact(redact.AlreadyRedacted(v1Content.Transcript), compact.MetadataFields{
- Agent: string(v1Content.Metadata.Agent),
- CLIVersion: versioninfo.Version,
- StartLine: v1Content.Metadata.GetTranscriptStart(),
- })
- require.NoError(t, err)
- require.NotNil(t, scopedCompacted)
- require.Greater(t, bytes.Count(fullCompacted, []byte{'\n'}), bytes.Count(scopedCompacted, []byte{'\n'}))
- expectedOffset := computeCompactOffset(ctx, v1Content.Transcript, fullCompacted, v1Content.Metadata)
- require.Positive(t, expectedOffset, "expected non-zero compact transcript start")
-
- var stdout bytes.Buffer
- result, migrateErr := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, migrateErr)
- assert.Equal(t, 1, result.migrated)
-
- v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
- require.NoError(t, err)
- v2MainTree, err := v2MainCommit.Tree()
- require.NoError(t, err)
-
- metadataFile, err := v2MainTree.File(cpID.Path() + "/0/" + paths.MetadataFileName)
- require.NoError(t, err)
- metadataContent, err := metadataFile.Contents()
- require.NoError(t, err)
-
- var metadata checkpoint.CommittedMetadata
- require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata))
- assert.Equal(t, expectedOffset, metadata.CheckpointTranscriptStart)
-
- storedCompact, err := v2Store.ReadSessionCompactTranscript(ctx, cpID, 0)
- require.NoError(t, err)
- assert.Equal(t, fullCompacted, storedCompact, "migration should persist cumulative compact transcript")
-}
-
-func TestMigrateCheckpointsV2_RepairsMissingFullTranscriptBeforeBackfill(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("112233aabbcc")
- writeV1Checkpoint(
- t, v1Store, cpID, "session-repair-001",
- []byte("{\"type\":\"assistant\",\"message\":\"repair me\"}\n"),
- []string{"repair prompt"},
- )
-
- // Initial migration to create v2 state.
- var initialRun bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result1.migrated)
-
- // Simulate interrupted migration by removing raw transcript files from every /full/* ref.
- removeV2SessionTranscriptFiles(t, repo, v2Store, cpID, 0)
-
- // Re-run migration: should requeue the missing raw transcript for final
- // generation packing and count as migrated (not skipped).
- var rerun bytes.Buffer
- result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
- require.NoError(t, rerunErr)
- assert.Equal(t, 1, result2.migrated)
- assert.Equal(t, 0, result2.failed)
- assert.Equal(t, 1, result2.repaired)
- assert.Empty(t, rerun.String())
-
- content, readErr := v2Store.ReadSessionContent(context.Background(), cpID, 0)
- require.NoError(t, readErr)
- assert.NotEmpty(t, content.Transcript, "raw full transcript should be restored in a packed /full/* generation")
- assert.False(t, hasCurrentFullSessionArtifactsForTest(t, repo, v2Store, cpID, 0),
- "rerun repair must not rehydrate migrated raw transcripts into /full/current")
-}
-
-func TestMigrateCheckpointsV2_SkipsRepairWhenArchivedFullExists(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
-
- cpID := id.MustCheckpointID("334455ddeeff")
- writeV1Checkpoint(
- t, v1Store, cpID, "session-repair-archive-001",
- []byte("{\"type\":\"assistant\",\"message\":\"repair from archive fallback\"}\n"),
- []string{"repair archive prompt"},
- )
-
- // Initial migration to seed v2.
- var initialRun bytes.Buffer
- result1, err := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &initialRun, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result1.migrated)
-
- // Fresh migration packs raw transcripts into an archived generation and
- // leaves /full/current empty.
- archivedRead, archivedReadErr := v2Store.ReadSessionContent(context.Background(), cpID, 0)
- require.NoError(t, archivedReadErr)
- assert.NotEmpty(t, archivedRead.Transcript)
-
- // Re-run migration: archived /full/* artifacts are sufficient, so it should
- // not rehydrate old raw transcripts into /full/current.
- var rerun bytes.Buffer
- result2, rerunErr := migrateCheckpointsV2(context.Background(), repo, v1Store, v2Store, &rerun, false)
- require.NoError(t, rerunErr)
- assert.Equal(t, 0, result2.migrated)
- assert.Equal(t, 1, result2.skipped)
- assert.NotContains(t, rerun.String(), "repaired partial v2 checkpoint state")
-
- ok, checkErr := hasFullSessionArtifacts(v2Store, cpID, 0)
- require.NoError(t, checkErr)
- assert.True(t, ok, "expected archived /full/* artifacts to count as present")
- assert.False(t, hasCurrentFullSessionArtifactsForTest(t, repo, v2Store, cpID, 0),
- "migration rerun must not copy archived artifacts back into /full/current")
-}
-
-func removeV2SessionTranscriptFiles(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) {
- t.Helper()
-
- for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
- removeV2SessionTranscriptFilesFromRef(t, repo, v2Store, refName, cpID, sessionIdx)
- }
-}
-
-func removeV2SessionTranscriptFilesFromRef(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, refName plumbing.ReferenceName, cpID id.CheckpointID, sessionIdx int) {
- t.Helper()
-
- parentHash, rootTreeHash, err := v2Store.GetRefState(refName)
- if err != nil {
- return
- }
-
- newRootHash, updateErr := checkpoint.UpdateSubtree(
- repo,
- rootTreeHash,
- []string{string(cpID[:2]), string(cpID[2:]), strconv.Itoa(sessionIdx)},
- nil,
- checkpoint.UpdateSubtreeOptions{
- MergeMode: checkpoint.MergeKeepExisting,
- DeleteNames: []string{
- paths.V2RawTranscriptFileName,
- paths.V2RawTranscriptFileName + ".001",
- paths.V2RawTranscriptFileName + ".002",
- paths.V2RawTranscriptHashFileName,
- },
- },
- )
- require.NoError(t, updateErr)
- if newRootHash == rootTreeHash {
- return
- }
-
- commitHash, commitErr := checkpoint.CreateCommit(context.Background(), repo, newRootHash, parentHash, "test: remove full transcript\n", "Test", "test@test.com")
- require.NoError(t, commitErr)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
-}
-
-func v2FullTreeForCheckpoint(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID) *object.Tree {
- t.Helper()
-
- for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
- _, rootTreeHash, err := v2Store.GetRefState(refName)
- if err != nil {
- continue
- }
- rootTree, err := repo.TreeObject(rootTreeHash)
- require.NoError(t, err)
- if _, treeErr := rootTree.Tree(cpID.Path()); treeErr == nil {
- return rootTree
- }
- }
-
- t.Fatalf("checkpoint %s not found in any v2 /full/* ref", cpID)
- return nil
-}
-
-func v2FullFileExistsForCheckpoint(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, relPath string) bool {
- t.Helper()
-
- for _, refName := range v2FullRefSearchOrderForTest(t, v2Store) {
- _, rootTreeHash, err := v2Store.GetRefState(refName)
- if err != nil {
- continue
- }
- rootTree, err := repo.TreeObject(rootTreeHash)
- require.NoError(t, err)
- if _, err := rootTree.File(cpID.Path() + "/" + relPath); err == nil {
- return true
- }
- }
-
- return false
-}
-
-func v2FullRefSearchOrderForTest(t *testing.T, v2Store *checkpoint.V2GitStore) []plumbing.ReferenceName {
- t.Helper()
-
- refNames := []plumbing.ReferenceName{plumbing.ReferenceName(paths.V2FullCurrentRefName)}
- archived, err := v2Store.ListArchivedGenerations()
- require.NoError(t, err)
- for i := len(archived) - 1; i >= 0; i-- {
- refNames = append(refNames, plumbing.ReferenceName(paths.V2FullRefPrefix+archived[i]))
- }
- return refNames
-}
-
-func hasCurrentFullSessionArtifactsForTest(t *testing.T, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int) bool {
- t.Helper()
-
- _, rootTreeHash, err := v2Store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
- require.NoError(t, err)
-
- rootTree, err := repo.TreeObject(rootTreeHash)
- require.NoError(t, err)
-
- sessionPath := cpID.Path() + "/" + strconv.Itoa(sessionIdx)
- sessionTree, err := rootTree.Tree(sessionPath)
- if err != nil {
- return false
- }
-
- hasTranscript := false
- for _, entry := range sessionTree.Entries {
- if entry.Name == paths.V2RawTranscriptFileName || strings.HasPrefix(entry.Name, paths.V2RawTranscriptFileName+".") {
- hasTranscript = true
- break
- }
- }
- if !hasTranscript {
- return false
- }
-
- _, err = sessionTree.File(paths.V2RawTranscriptHashFileName)
- return err == nil
-}
-
-func TestBuildMigrateWriteOpts_PromptSeparatorRoundTrip(t *testing.T) {
- t.Parallel()
-
- cpID := id.MustCheckpointID("123456abcdef")
- rawPrompts := strings.Join([]string{
- "first line\nwith newline",
- "second prompt",
- }, checkpoint.PromptSeparator)
-
- opts := buildMigrateWriteOpts(&checkpoint.SessionContent{
- Metadata: checkpoint.CommittedMetadata{
- SessionID: "session-prompts-001",
- Strategy: "manual-commit",
- },
- Prompts: rawPrompts,
- }, checkpoint.CommittedInfo{
- CheckpointID: cpID,
- }, nil)
-
- require.Len(t, opts.Prompts, 2)
- assert.Equal(t, "first line\nwith newline", opts.Prompts[0])
- assert.Equal(t, "second prompt", opts.Prompts[1])
-}
-
-func TestLatestMigratedV2SessionIndex_Empty(t *testing.T) {
- t.Parallel()
-
- latest, ok := latestMigratedV2SessionIndex(nil)
- assert.Equal(t, -1, latest)
- assert.False(t, ok)
-}
-
-func TestMigrateCheckpointsV2_PreservesPromptAttributions(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("aabb22334455")
- promptAttrs := json.RawMessage(`[{"prompt_index":0,"user_lines":["main.go:10"]}]`)
-
- err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: "session-pa-001",
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"assistant\",\"message\":\"pa test\"}\n")),
- Prompts: []string{"test prompt"},
- PromptAttributionsJSON: promptAttrs,
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Verify v1 has prompt_attributions
- v1Content, err := v1Store.ReadSessionContent(ctx, cpID, 0)
- require.NoError(t, err)
- require.NotNil(t, v1Content.Metadata.PromptAttributions, "v1 should have prompt_attributions")
-
- // Migrate
- var stdout bytes.Buffer
- result, err := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result.migrated)
-
- // Read v2 session metadata from /main ref and verify prompt_attributions preserved
- v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
- require.NoError(t, err)
- v2MainTree, err := v2MainCommit.Tree()
- require.NoError(t, err)
-
- metadataFile, err := v2MainTree.File(cpID.Path() + "/0/" + paths.MetadataFileName)
- require.NoError(t, err)
- metadataContent, err := metadataFile.Contents()
- require.NoError(t, err)
-
- var metadata checkpoint.CommittedMetadata
- require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata))
- assert.JSONEq(t, string(promptAttrs), string(metadata.PromptAttributions),
- "v2 session metadata should preserve prompt_attributions from v1")
-}
-
-func TestMigrateCheckpointsV2_PreservesCombinedAttribution(t *testing.T) {
- t.Parallel()
- repo := initMigrateTestRepo(t)
- v1Store, v2Store := newMigrateStores(repo)
- ctx := context.Background()
-
- cpID := id.MustCheckpointID("ccdd55667788")
-
- // Write two sessions so combined attribution is meaningful
- writeV1Checkpoint(
- t, v1Store, cpID, "session-ca-001",
- []byte("{\"type\":\"assistant\",\"message\":\"session 1\"}\n"),
- []string{"prompt 1"},
- )
- writeV1Checkpoint(
- t, v1Store, cpID, "session-ca-002",
- []byte("{\"type\":\"assistant\",\"message\":\"session 2\"}\n"),
- []string{"prompt 2"},
- )
-
- // Inject CombinedAttribution into v1 root summary
- combined := &checkpoint.InitialAttribution{
- CalculatedAt: time.Date(2026, 4, 15, 0, 18, 47, 0, time.UTC),
- AgentLines: 119,
- AgentRemoved: 94,
- HumanAdded: 3,
- HumanModified: 0,
- HumanRemoved: 1,
- TotalCommitted: 122,
- TotalLinesChanged: 217,
- AgentPercentage: 98.15668202764977,
- MetricVersion: 2,
- }
- err := v1Store.UpdateCheckpointSummary(ctx, cpID, combined)
- require.NoError(t, err)
-
- // Verify v1 root summary has CombinedAttribution
- v1Summary, err := v1Store.ReadCommitted(ctx, cpID)
- require.NoError(t, err)
- require.NotNil(t, v1Summary.CombinedAttribution, "v1 should have combined_attribution")
-
- // Migrate
- var stdout bytes.Buffer
- result, err := migrateCheckpointsV2(ctx, repo, v1Store, v2Store, &stdout, false)
- require.NoError(t, err)
- assert.Equal(t, 1, result.migrated)
-
- // Read v2 root summary and verify CombinedAttribution preserved
- v2Summary, err := v2Store.ReadCommitted(ctx, cpID)
- require.NoError(t, err)
- require.NotNil(t, v2Summary)
- require.NotNil(t, v2Summary.CombinedAttribution,
- "v2 root summary should preserve combined_attribution from v1")
- assert.Equal(t, combined.CalculatedAt, v2Summary.CombinedAttribution.CalculatedAt)
- assert.Equal(t, combined.AgentLines, v2Summary.CombinedAttribution.AgentLines)
- assert.Equal(t, combined.AgentRemoved, v2Summary.CombinedAttribution.AgentRemoved)
- assert.Equal(t, combined.HumanAdded, v2Summary.CombinedAttribution.HumanAdded)
- assert.Equal(t, combined.HumanModified, v2Summary.CombinedAttribution.HumanModified)
- assert.Equal(t, combined.HumanRemoved, v2Summary.CombinedAttribution.HumanRemoved)
- assert.Equal(t, combined.TotalCommitted, v2Summary.CombinedAttribution.TotalCommitted)
- assert.Equal(t, combined.TotalLinesChanged, v2Summary.CombinedAttribution.TotalLinesChanged)
- assert.InDelta(t, combined.AgentPercentage, v2Summary.CombinedAttribution.AgentPercentage, 0.001)
- assert.Equal(t, combined.MetricVersion, v2Summary.CombinedAttribution.MetricVersion)
-}
-
-func TestSortMigratableCheckpoints(t *testing.T) {
- t.Parallel()
-
- t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- t2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
- t3 := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
-
- tests := []struct {
- name string
- input []checkpoint.CommittedInfo
- want []id.CheckpointID
- }{
- {
- name: "chronological order",
- input: []checkpoint.CommittedInfo{
- {CheckpointID: id.MustCheckpointID("000000000003"), CreatedAt: t3},
- {CheckpointID: id.MustCheckpointID("000000000001"), CreatedAt: t1},
- {CheckpointID: id.MustCheckpointID("000000000002"), CreatedAt: t2},
- },
- want: []id.CheckpointID{
- id.MustCheckpointID("000000000001"),
- id.MustCheckpointID("000000000002"),
- id.MustCheckpointID("000000000003"),
- },
- },
- {
- name: "ties on CreatedAt break by checkpoint ID",
- input: []checkpoint.CommittedInfo{
- {CheckpointID: id.MustCheckpointID("0000000000bb"), CreatedAt: t1},
- {CheckpointID: id.MustCheckpointID("0000000000aa"), CreatedAt: t1},
- {CheckpointID: id.MustCheckpointID("0000000000cc"), CreatedAt: t1},
- },
- want: []id.CheckpointID{
- id.MustCheckpointID("0000000000aa"),
- id.MustCheckpointID("0000000000bb"),
- id.MustCheckpointID("0000000000cc"),
- },
- },
- {
- name: "zero CreatedAt sorts after non-zero, ties by ID",
- input: []checkpoint.CommittedInfo{
- {CheckpointID: id.MustCheckpointID("0000000000aa")},
- {CheckpointID: id.MustCheckpointID("000000000002"), CreatedAt: t2},
- {CheckpointID: id.MustCheckpointID("0000000000bb")},
- {CheckpointID: id.MustCheckpointID("000000000001"), CreatedAt: t1},
- },
- want: []id.CheckpointID{
- id.MustCheckpointID("000000000001"),
- id.MustCheckpointID("000000000002"),
- id.MustCheckpointID("0000000000aa"),
- id.MustCheckpointID("0000000000bb"),
- },
- },
- {
- name: "all-zero CreatedAt sorts by ID",
- input: []checkpoint.CommittedInfo{
- {CheckpointID: id.MustCheckpointID("0000000000cc")},
- {CheckpointID: id.MustCheckpointID("0000000000aa")},
- {CheckpointID: id.MustCheckpointID("0000000000bb")},
- },
- want: []id.CheckpointID{
- id.MustCheckpointID("0000000000aa"),
- id.MustCheckpointID("0000000000bb"),
- id.MustCheckpointID("0000000000cc"),
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- input := make([]checkpoint.CommittedInfo, len(tt.input))
- copy(input, tt.input)
- sortMigratableCheckpoints(input)
- got := make([]id.CheckpointID, len(input))
- for i, c := range input {
- got[i] = c.CheckpointID
- }
- assert.Equal(t, tt.want, got)
- })
- }
-}
diff --git a/cli/resume.go b/cli/resume.go
index 540fa26..52d724d 100644
--- a/cli/resume.go
+++ b/cli/resume.go
@@ -6,11 +6,7 @@ import (
"fmt"
"io"
"log/slog"
- "os"
- "path/filepath"
- "sort"
- "github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/external"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
@@ -813,277 +809,3 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id
}
return nil
}
-
-// tryReadCheckpointFromTree attempts to read checkpoint metadata from a metadata tree.
-func tryReadCheckpointFromTree(ctx context.Context, tree *object.Tree, repo *git.Repository, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) {
- cpSubtree, cpErr := tree.Tree(checkpointID.Path())
- if cpErr != nil {
- return nil, fmt.Errorf("checkpoint subtree not found: %w", cpErr)
- }
- ft := checkpoint.NewFetchingTree(ctx, cpSubtree, repo.Storer, FetchBlobsByHash)
- if _, pfErr := ft.PreFetch(); pfErr != nil {
- logging.Debug(
- ctx, "tryReadCheckpointFromTree: PreFetch failed",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("error", pfErr.Error()),
- )
- }
- metadata, err := strategy.ReadCheckpointMetadataFromSubtree(ft, checkpointID.Path())
- if err != nil {
- return nil, fmt.Errorf("failed to read checkpoint metadata: %w", err)
- }
- return metadata, nil
-}
-
-// resumeSession restores and displays the resume command for a specific session.
-// For multi-session checkpoints, restores ALL sessions and shows commands for each.
-// If force is false, prompts for confirmation when local logs have newer timestamps.
-// The caller must provide the already-resolved checkpoint metadata to avoid redundant lookups
-// and to support both local and remote metadata trees.
-func resumeSession(ctx context.Context, w, errW io.Writer, metadata *strategy.CheckpointInfo, force bool) error {
- checkpointID := metadata.CheckpointID
- sessionID := metadata.SessionID
-
- // Resolve agent from checkpoint metadata (same as rewind)
- ag, err := strategy.ResolveAgentForRewind(metadata.Agent)
- if err != nil {
- return fmt.Errorf("failed to resolve agent: %w", err)
- }
-
- // Initialize logging context with agent
- logCtx := logging.WithAgent(logging.WithComponent(ctx, "resume"), ag.Name())
-
- logging.Debug(
- logCtx, "resume session started",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- )
-
- // Get worktree root for session directory lookup
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return fmt.Errorf("failed to get worktree root: %w", err)
- }
-
- sessionDir, err := ag.GetSessionDir(repoRoot)
- if err != nil {
- return fmt.Errorf("failed to determine session directory: %w", err)
- }
-
- // Create directory if it doesn't exist
- if err := os.MkdirAll(sessionDir, 0o700); err != nil {
- return fmt.Errorf("failed to create session directory: %w", err)
- }
-
- // Get strategy and restore sessions using full checkpoint data
- start := GetStrategy(ctx)
-
- // Use RestoreLogsOnly via LogsOnlyRestorer interface for multi-session support
- // Create a logs-only rewind point with Agent populated (same as rewind)
- point := strategy.RewindPoint{
- IsLogsOnly: true,
- CheckpointID: checkpointID,
- Agent: metadata.Agent,
- }
-
- sessions, restoreErr := start.RestoreLogsOnly(ctx, w, errW, point, force)
- if restoreErr != nil || len(sessions) == 0 {
- // Fall back to single-session restore (e.g., old checkpoints without agent metadata)
- return resumeSingleSession(ctx, w, errW, ag, sessionID, checkpointID, repoRoot, force)
- }
-
- logging.Debug(
- logCtx, "resume session completed",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.Int("session_count", len(sessions)),
- )
-
- return displayRestoredSessions(w, sessions)
-}
-
-// displayRestoredSessions sorts sessions by CreatedAt and prints resume commands.
-func displayRestoredSessions(w io.Writer, sessions []strategy.RestoredSession) error {
- sort.SliceStable(sessions, func(i, j int) bool {
- return sessions[i].CreatedAt.Before(sessions[j].CreatedAt)
- })
-
- if len(sessions) > 1 {
- fmt.Fprintf(w, "\n✓ Restored %d sessions. To continue:\n", len(sessions))
- } else if len(sessions) == 1 {
- fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID)
- fmt.Fprintf(w, "\nTo continue this session:\n")
- }
-
- isMulti := len(sessions) > 1
- for i, sess := range sessions {
- sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent)
- if err != nil {
- return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, err)
- }
- printSessionCommand(w, sessionAgent.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1)
- }
-
- return nil
-}
-
-// resumeSingleSession restores a single session (fallback when multi-session restore fails).
-// Always overwrites existing session logs to ensure consistency with checkpoint state.
-// If force is false, prompts for confirmation when local log has newer timestamps.
-func resumeSingleSession(ctx context.Context, w, errW io.Writer, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, repoRoot string, force bool) error {
- sessionLogPath, err := resolveTranscriptPath(ctx, sessionID, ag)
- if err != nil {
- return fmt.Errorf("failed to resolve transcript path: %w", err)
- }
-
- if checkpointID.IsEmpty() {
- logging.Debug(
- ctx, "resume session: empty checkpoint ID",
- slog.String("checkpoint_id", checkpointID.String()),
- )
- fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID)
- fmt.Fprintf(w, "\nTo continue this session:\n")
- fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
- return nil
- }
-
- var logContent []byte
- err = nil // Reset before v2/v1 resolution to avoid stale error from earlier code paths
- if settings.IsCheckpointsV2Enabled(ctx) {
- repo, repoErr := openRepository(ctx)
- if repoErr == nil {
- v2URL, fetchRemoteErr := remote.FetchURL(ctx)
- if fetchRemoteErr != nil {
- logging.Debug(
- ctx, "resume: using origin for v2 session log fetch remote",
- slog.String("error", fetchRemoteErr.Error()),
- )
- v2URL = ""
- }
- v2Store := checkpoint.NewV2GitStore(repo, v2URL)
- var v2Err error
- logContent, _, v2Err = v2Store.GetSessionLog(ctx, checkpointID)
- if v2Err != nil {
- logging.Debug(
- ctx, "v2 GetSessionLog failed, falling back to v1",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("error", v2Err.Error()),
- )
- }
- }
- }
- if len(logContent) == 0 {
- logContent, _, err = checkpoint.LookupSessionLog(ctx, checkpointID)
- }
- if err != nil {
- if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) {
- logging.Debug(
- ctx, "resume session completed (no metadata)",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- )
- fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID)
- fmt.Fprintf(w, "\nTo continue this session:\n")
- fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
- return nil
- }
- logging.Error(
- ctx, "resume session failed",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to get session log: %w", err)
- }
-
- // Check if local file has newer timestamps than checkpoint
- if !force {
- localTime := paths.GetLastTimestampFromFile(sessionLogPath)
- checkpointTime := paths.GetLastTimestampFromBytes(logContent)
- status := strategy.ClassifyTimestamps(localTime, checkpointTime)
-
- if status == strategy.StatusLocalNewer {
- sessions := []strategy.SessionRestoreInfo{{
- SessionID: sessionID,
- Status: status,
- LocalTime: localTime,
- CheckpointTime: checkpointTime,
- }}
- shouldOverwrite, promptErr := strategy.PromptOverwriteNewerLogs(errW, sessions)
- if promptErr != nil {
- return fmt.Errorf("failed to get confirmation: %w", promptErr)
- }
- if !shouldOverwrite {
- fmt.Fprintf(w, "Resume cancelled. Local session log preserved.\n")
- return nil
- }
- }
- }
-
- // Ensure parent directory exists
- if err := os.MkdirAll(filepath.Dir(sessionLogPath), 0o750); err != nil {
- return fmt.Errorf("failed to create session directory: %w", err)
- }
-
- agentSession := &agent.AgentSession{
- SessionID: sessionID,
- AgentName: ag.Name(),
- RepoPath: repoRoot,
- SessionRef: sessionLogPath,
- NativeData: logContent,
- }
-
- // Write the session using the agent's WriteSession method
- if err := ag.WriteSession(ctx, agentSession); err != nil {
- logging.Error(
- ctx, "resume session failed during write",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to write session: %w", err)
- }
-
- logging.Debug(
- ctx, "resume session completed",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- )
-
- fmt.Fprintf(w, "✓ Session restored to: %s\n", sessionLogPath)
- fmt.Fprintf(w, " Session: %s\n", sessionID)
- fmt.Fprintf(w, "\nTo continue this session:\n")
- fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
-
- return nil
-}
-
-func promptFetchFromRemote(branchName string) (bool, error) {
- var confirmed bool
-
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title(fmt.Sprintf("Branch '%s' not found locally. Fetch from origin?", branchName)).
- Value(&confirmed),
- ),
- )
-
- if err := form.Run(); err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- return false, nil
- }
- return false, fmt.Errorf("failed to get confirmation: %w", err)
- }
-
- return confirmed, nil
-}
-
-// firstLine returns the first line of a string
-func firstLine(s string) string {
- for i, c := range s {
- if c == '\n' {
- return s[:i]
- }
- }
- return s
-}
diff --git a/cli/resume_2.go b/cli/resume_2.go
new file mode 100644
index 0000000..e030f2a
--- /dev/null
+++ b/cli/resume_2.go
@@ -0,0 +1,299 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+
+ "charm.land/huh/v2"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// tryReadCheckpointFromTree attempts to read checkpoint metadata from a metadata tree.
+func tryReadCheckpointFromTree(ctx context.Context, tree *object.Tree, repo *git.Repository, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) {
+ cpSubtree, cpErr := tree.Tree(checkpointID.Path())
+ if cpErr != nil {
+ return nil, fmt.Errorf("checkpoint subtree not found: %w", cpErr)
+ }
+ ft := checkpoint.NewFetchingTree(ctx, cpSubtree, repo.Storer, FetchBlobsByHash)
+ if _, pfErr := ft.PreFetch(); pfErr != nil {
+ logging.Debug(
+ ctx, "tryReadCheckpointFromTree: PreFetch failed",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("error", pfErr.Error()),
+ )
+ }
+ metadata, err := strategy.ReadCheckpointMetadataFromSubtree(ft, checkpointID.Path())
+ if err != nil {
+ return nil, fmt.Errorf("failed to read checkpoint metadata: %w", err)
+ }
+ return metadata, nil
+}
+
+// resumeSession restores and displays the resume command for a specific session.
+// For multi-session checkpoints, restores ALL sessions and shows commands for each.
+// If force is false, prompts for confirmation when local logs have newer timestamps.
+// The caller must provide the already-resolved checkpoint metadata to avoid redundant lookups
+// and to support both local and remote metadata trees.
+func resumeSession(ctx context.Context, w, errW io.Writer, metadata *strategy.CheckpointInfo, force bool) error {
+ checkpointID := metadata.CheckpointID
+ sessionID := metadata.SessionID
+
+ // Resolve agent from checkpoint metadata (same as rewind)
+ ag, err := strategy.ResolveAgentForRewind(metadata.Agent)
+ if err != nil {
+ return fmt.Errorf("failed to resolve agent: %w", err)
+ }
+
+ // Initialize logging context with agent
+ logCtx := logging.WithAgent(logging.WithComponent(ctx, "resume"), ag.Name())
+
+ logging.Debug(
+ logCtx, "resume session started",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ )
+
+ // Get worktree root for session directory lookup
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get worktree root: %w", err)
+ }
+
+ sessionDir, err := ag.GetSessionDir(repoRoot)
+ if err != nil {
+ return fmt.Errorf("failed to determine session directory: %w", err)
+ }
+
+ // Create directory if it doesn't exist
+ if err := os.MkdirAll(sessionDir, 0o700); err != nil {
+ return fmt.Errorf("failed to create session directory: %w", err)
+ }
+
+ // Get strategy and restore sessions using full checkpoint data
+ start := GetStrategy(ctx)
+
+ // Use RestoreLogsOnly via LogsOnlyRestorer interface for multi-session support
+ // Create a logs-only rewind point with Agent populated (same as rewind)
+ point := strategy.RewindPoint{
+ IsLogsOnly: true,
+ CheckpointID: checkpointID,
+ Agent: metadata.Agent,
+ }
+
+ sessions, restoreErr := start.RestoreLogsOnly(ctx, w, errW, point, force)
+ if restoreErr != nil || len(sessions) == 0 {
+ // Fall back to single-session restore (e.g., old checkpoints without agent metadata)
+ return resumeSingleSession(ctx, w, errW, ag, sessionID, checkpointID, repoRoot, force)
+ }
+
+ logging.Debug(
+ logCtx, "resume session completed",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.Int("session_count", len(sessions)),
+ )
+
+ return displayRestoredSessions(w, sessions)
+}
+
+// displayRestoredSessions sorts sessions by CreatedAt and prints resume commands.
+func displayRestoredSessions(w io.Writer, sessions []strategy.RestoredSession) error {
+ sort.SliceStable(sessions, func(i, j int) bool {
+ return sessions[i].CreatedAt.Before(sessions[j].CreatedAt)
+ })
+
+ if len(sessions) > 1 {
+ fmt.Fprintf(w, "\n✓ Restored %d sessions. To continue:\n", len(sessions))
+ } else if len(sessions) == 1 {
+ fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID)
+ fmt.Fprintf(w, "\nTo continue this session:\n")
+ }
+
+ isMulti := len(sessions) > 1
+ for i, sess := range sessions {
+ sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent)
+ if err != nil {
+ return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, err)
+ }
+ printSessionCommand(w, sessionAgent.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1)
+ }
+
+ return nil
+}
+
+// resumeSingleSession restores a single session (fallback when multi-session restore fails).
+// Always overwrites existing session logs to ensure consistency with checkpoint state.
+// If force is false, prompts for confirmation when local log has newer timestamps.
+func resumeSingleSession(ctx context.Context, w, errW io.Writer, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, repoRoot string, force bool) error {
+ sessionLogPath, err := resolveTranscriptPath(ctx, sessionID, ag)
+ if err != nil {
+ return fmt.Errorf("failed to resolve transcript path: %w", err)
+ }
+
+ if checkpointID.IsEmpty() {
+ logging.Debug(
+ ctx, "resume session: empty checkpoint ID",
+ slog.String("checkpoint_id", checkpointID.String()),
+ )
+ fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID)
+ fmt.Fprintf(w, "\nTo continue this session:\n")
+ fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
+ return nil
+ }
+
+ var logContent []byte
+ err = nil // Reset before v2/v1 resolution to avoid stale error from earlier code paths
+ if settings.IsCheckpointsV2Enabled(ctx) {
+ repo, repoErr := openRepository(ctx)
+ if repoErr == nil {
+ v2URL, fetchRemoteErr := remote.FetchURL(ctx)
+ if fetchRemoteErr != nil {
+ logging.Debug(
+ ctx, "resume: using origin for v2 session log fetch remote",
+ slog.String("error", fetchRemoteErr.Error()),
+ )
+ v2URL = ""
+ }
+ v2Store := checkpoint.NewV2GitStore(repo, v2URL)
+ var v2Err error
+ logContent, _, v2Err = v2Store.GetSessionLog(ctx, checkpointID)
+ if v2Err != nil {
+ logging.Debug(
+ ctx, "v2 GetSessionLog failed, falling back to v1",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("error", v2Err.Error()),
+ )
+ }
+ }
+ }
+ if len(logContent) == 0 {
+ logContent, _, err = checkpoint.LookupSessionLog(ctx, checkpointID)
+ }
+ if err != nil {
+ if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) {
+ logging.Debug(
+ ctx, "resume session completed (no metadata)",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ )
+ fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID)
+ fmt.Fprintf(w, "\nTo continue this session:\n")
+ fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
+ return nil
+ }
+ logging.Error(
+ ctx, "resume session failed",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to get session log: %w", err)
+ }
+
+ // Check if local file has newer timestamps than checkpoint
+ if !force {
+ localTime := paths.GetLastTimestampFromFile(sessionLogPath)
+ checkpointTime := paths.GetLastTimestampFromBytes(logContent)
+ status := strategy.ClassifyTimestamps(localTime, checkpointTime)
+
+ if status == strategy.StatusLocalNewer {
+ sessions := []strategy.SessionRestoreInfo{{
+ SessionID: sessionID,
+ Status: status,
+ LocalTime: localTime,
+ CheckpointTime: checkpointTime,
+ }}
+ shouldOverwrite, promptErr := strategy.PromptOverwriteNewerLogs(errW, sessions)
+ if promptErr != nil {
+ return fmt.Errorf("failed to get confirmation: %w", promptErr)
+ }
+ if !shouldOverwrite {
+ fmt.Fprintf(w, "Resume cancelled. Local session log preserved.\n")
+ return nil
+ }
+ }
+ }
+
+ // Ensure parent directory exists
+ if err := os.MkdirAll(filepath.Dir(sessionLogPath), 0o750); err != nil {
+ return fmt.Errorf("failed to create session directory: %w", err)
+ }
+
+ agentSession := &agent.AgentSession{
+ SessionID: sessionID,
+ AgentName: ag.Name(),
+ RepoPath: repoRoot,
+ SessionRef: sessionLogPath,
+ NativeData: logContent,
+ }
+
+ // Write the session using the agent's WriteSession method
+ if err := ag.WriteSession(ctx, agentSession); err != nil {
+ logging.Error(
+ ctx, "resume session failed during write",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to write session: %w", err)
+ }
+
+ logging.Debug(
+ ctx, "resume session completed",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ )
+
+ fmt.Fprintf(w, "✓ Session restored to: %s\n", sessionLogPath)
+ fmt.Fprintf(w, " Session: %s\n", sessionID)
+ fmt.Fprintf(w, "\nTo continue this session:\n")
+ fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID))
+
+ return nil
+}
+
+func promptFetchFromRemote(branchName string) (bool, error) {
+ var confirmed bool
+
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title(fmt.Sprintf("Branch '%s' not found locally. Fetch from origin?", branchName)).
+ Value(&confirmed),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return false, nil
+ }
+ return false, fmt.Errorf("failed to get confirmation: %w", err)
+ }
+
+ return confirmed, nil
+}
+
+// firstLine returns the first line of a string
+func firstLine(s string) string {
+ for i, c := range s {
+ if c == '\n' {
+ return s[:i]
+ }
+ }
+ return s
+}
diff --git a/cli/review/cmd_2_test.go b/cli/review/cmd_2_test.go
new file mode 100644
index 0000000..7a41104
--- /dev/null
+++ b/cli/review/cmd_2_test.go
@@ -0,0 +1,318 @@
+package review_test
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/review"
+ reviewtypes "github.com/GrayCodeAI/trace/cli/review/types"
+ "github.com/GrayCodeAI/trace/cli/settings"
+)
+
+// TestComposeMultiAgentSinks exercises the sink-composition helper directly
+// with explicit isTTY/canPrompt values, so we get real coverage of the TTY
+// branch without depending on os.Stdout being a terminal during `go test`.
+func TestComposeMultiAgentSinks(t *testing.T) {
+ t.Parallel()
+
+ provider := &stubCmdSynthesisProvider{}
+ noopCancel := func() {}
+
+ tests := []struct {
+ name string
+ isTTY bool
+ canPrompt bool
+ provider review.SynthesisProvider
+ wantTUI bool
+ wantDump bool
+ wantSynth bool
+ wantTotal int
+ }{
+ {
+ name: "non-tty omits tui and synth",
+ isTTY: false,
+ canPrompt: false,
+ provider: provider,
+ wantDump: true,
+ wantTotal: 1,
+ },
+ {
+ name: "tty with provider and prompt appends synth",
+ isTTY: true,
+ canPrompt: true,
+ provider: provider,
+ wantTUI: true,
+ wantDump: true,
+ wantSynth: true,
+ wantTotal: 3,
+ },
+ {
+ name: "tty without provider skips synth",
+ isTTY: true,
+ canPrompt: true,
+ provider: nil,
+ wantTUI: true,
+ wantDump: true,
+ wantTotal: 2,
+ },
+ {
+ name: "tty without prompt skips synth even with provider",
+ isTTY: true,
+ canPrompt: false,
+ provider: provider,
+ wantTUI: true,
+ wantDump: true,
+ wantTotal: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ sinks := review.ExposedComposeMultiAgentSinks(review.SinkComposeInputs{
+ Out: &bytes.Buffer{},
+ IsTTY: tt.isTTY,
+ CanPrompt: tt.canPrompt,
+ AgentNames: []string{"a", "b"},
+ CancelRun: noopCancel,
+ SynthesisProvider: tt.provider,
+ })
+ if got := len(sinks); got != tt.wantTotal {
+ t.Fatalf("len(sinks)=%d, want %d", got, tt.wantTotal)
+ }
+ _, hasTUI := review.ExposedFindTUISink(sinks)
+ if hasTUI != tt.wantTUI {
+ t.Errorf("findTUISink found=%v, want %v", hasTUI, tt.wantTUI)
+ }
+ var hasDump, hasSynth bool
+ for _, s := range sinks {
+ switch s.(type) {
+ case review.DumpSink:
+ hasDump = true
+ case review.SynthesisSink:
+ hasSynth = true
+ }
+ }
+ if hasDump != tt.wantDump {
+ t.Errorf("DumpSink present=%v, want %v", hasDump, tt.wantDump)
+ }
+ if hasSynth != tt.wantSynth {
+ t.Errorf("SynthesisSink present=%v, want %v", hasSynth, tt.wantSynth)
+ }
+ })
+ }
+}
+
+func TestComposeSingleAgentSinks(t *testing.T) {
+ t.Parallel()
+
+ noopCancel := func() {}
+
+ tests := []struct {
+ name string
+ isTTY bool
+ canPrompt bool
+ wantTUI bool
+ wantDump bool
+ wantTotal int
+ wantOutput string
+ }{
+ {
+ name: "non-tty prints running line and uses dump only",
+ wantDump: true,
+ wantTotal: 1,
+ wantOutput: "Running review with agent-a...",
+ },
+ {
+ name: "tty uses tui and dump",
+ isTTY: true,
+ canPrompt: true,
+ wantTUI: true,
+ wantDump: true,
+ wantTotal: 2,
+ },
+ {
+ name: "tty without prompt falls back to running line",
+ isTTY: true,
+ canPrompt: false,
+ wantDump: true,
+ wantTotal: 1,
+ wantOutput: "Running review with agent-a...",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ out := &bytes.Buffer{}
+ sinks := review.ExposedComposeSingleAgentSinks(review.SingleAgentSinkComposeInputs{
+ Out: out,
+ IsTTY: tt.isTTY,
+ CanPrompt: tt.canPrompt,
+ AgentName: "agent-a",
+ CancelRun: noopCancel,
+ })
+ if got := len(sinks); got != tt.wantTotal {
+ t.Fatalf("len(sinks)=%d, want %d", got, tt.wantTotal)
+ }
+ _, hasTUI := review.ExposedFindTUISink(sinks)
+ if hasTUI != tt.wantTUI {
+ t.Errorf("findTUISink found=%v, want %v", hasTUI, tt.wantTUI)
+ }
+ var hasDump, hasSynth bool
+ for _, s := range sinks {
+ switch s.(type) {
+ case review.DumpSink:
+ hasDump = true
+ case review.SynthesisSink:
+ hasSynth = true
+ }
+ }
+ if hasDump != tt.wantDump {
+ t.Errorf("DumpSink present=%v, want %v", hasDump, tt.wantDump)
+ }
+ if hasSynth {
+ t.Error("SynthesisSink should not be present for single-agent reviews")
+ }
+ if tt.wantOutput != "" && !strings.Contains(out.String(), tt.wantOutput) {
+ t.Errorf("output missing %q:\n%s", tt.wantOutput, out.String())
+ }
+ if tt.wantOutput == "" && out.Len() != 0 {
+ t.Errorf("expected no pre-run output, got:\n%s", out.String())
+ }
+ })
+ }
+}
+
+func TestComposeSinks_TUIWritersRunBeforePostRunWriters(t *testing.T) {
+ t.Parallel()
+ provider := &stubSynthesisProvider{}
+
+ multi := review.ExposedComposeMultiAgentSinks(review.SinkComposeInputs{
+ Out: &bytes.Buffer{},
+ IsTTY: true,
+ CanPrompt: true,
+ AgentNames: []string{"a", "b"},
+ CancelRun: func() {},
+ SynthesisProvider: provider,
+ })
+ if len(multi) != 3 {
+ t.Fatalf("multi sinks len = %d, want 3", len(multi))
+ }
+ if _, ok := multi[0].(*review.TUISink); !ok {
+ t.Fatalf("multi sink[0] = %T, want *TUISink", multi[0])
+ }
+ if _, ok := multi[1].(review.DumpSink); !ok {
+ t.Fatalf("multi sink[1] = %T, want DumpSink", multi[1])
+ }
+ if _, ok := multi[2].(review.SynthesisSink); !ok {
+ t.Fatalf("multi sink[2] = %T, want SynthesisSink", multi[2])
+ }
+
+ single := review.ExposedComposeSingleAgentSinks(review.SingleAgentSinkComposeInputs{
+ Out: &bytes.Buffer{},
+ IsTTY: true,
+ CanPrompt: true,
+ AgentName: "a",
+ CancelRun: func() {},
+ })
+ if len(single) != 2 {
+ t.Fatalf("single sinks len = %d, want 2", len(single))
+ }
+ if _, ok := single[0].(*review.TUISink); !ok {
+ t.Fatalf("single sink[0] = %T, want *TUISink", single[0])
+ }
+ if _, ok := single[1].(review.DumpSink); !ok {
+ t.Fatalf("single sink[1] = %T, want DumpSink", single[1])
+ }
+}
+
+// TestFindTUISink_NoTUIInSlice covers the not-found path so the caller's
+// `if tuiSink, ok := findTUISink(sinks); ok` branch is exercised in both
+// directions.
+func TestFindTUISink_NoTUIInSlice(t *testing.T) {
+ t.Parallel()
+ sinks := []reviewtypes.Sink{review.DumpSink{W: &bytes.Buffer{}}}
+ if tui, ok := review.ExposedFindTUISink(sinks); ok || tui != nil {
+ t.Errorf("findTUISink on dump-only slice returned (%v, %v); want (nil, false)", tui, ok)
+ }
+}
+
+// TestDispatchFork_SynthesisSinkNilProviderNoComposition verifies that when
+// deps.SynthesisProvider is nil, the command runs without panicking and does
+// not attempt to synthesize (no synthesis output appears).
+func TestDispatchFork_SynthesisSinkNilProviderNoComposition(t *testing.T) {
+ setupCmdTestRepo(t)
+
+ if err := review.SaveReviewConfig(context.Background(), map[string]settings.ReviewConfig{
+ "agent-a": {Prompt: "review"},
+ "agent-b": {Prompt: "review"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ multiPickerFn := func(_ context.Context, eligible []review.AgentChoice) (review.PickedAgents, error) {
+ names := make([]string, 0, len(eligible))
+ for _, e := range eligible {
+ names = append(names, e.Name)
+ }
+ return review.PickedAgents{Names: names, PerRun: ""}, nil
+ }
+
+ installed := []types.AgentName{"agent-a", "agent-b"}
+ deps := newDispatchTestDeps(t, installed, []string{"agent-a", "agent-b"}, multiPickerFn, nil)
+ deps.SynthesisProvider = nil // explicitly nil — synthesis unavailable
+
+ buf := &bytes.Buffer{}
+ cmd := review.NewCommand(deps)
+ cmd.SetOut(buf)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // No synthesis output expected.
+ if strings.Contains(buf.String(), "synthesis") {
+ t.Errorf("no synthesis output expected when provider is nil, got: %s", buf.String())
+ }
+}
+
+// TestDispatchFork_SingleAgentNoSynthesis verifies that the single-agent path
+// never invokes synthesis (synthesis is multi-agent only). We set a provider
+// but use a single launchable agent; the command should complete without
+// calling the synthesis provider.
+func TestDispatchFork_SingleAgentNoSynthesis(t *testing.T) {
+ setupCmdTestRepo(t)
+ installHooksForCmdTest(t, "cursor")
+
+ if err := review.SaveReviewConfig(context.Background(), map[string]settings.ReviewConfig{
+ "cursor": {Prompt: "review"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ provider := &stubCmdSynthesisProvider{}
+
+ // cursor is installed but not launchable (ReviewerFor returns nil).
+ installed := []types.AgentName{"cursor"}
+ deps := newDispatchTestDeps(t, installed, nil /* no launchable */, nil, nil)
+ deps.SynthesisProvider = provider
+
+ buf := &bytes.Buffer{}
+ cmd := review.NewCommand(deps)
+ cmd.SetOut(buf)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if provider.called {
+ t.Error("synthesis provider should NOT be called on single-agent path")
+ }
+}
diff --git a/cli/review/cmd_test.go b/cli/review/cmd_test.go
index f47591c..bfd84a8 100644
--- a/cli/review/cmd_test.go
+++ b/cli/review/cmd_test.go
@@ -743,308 +743,3 @@ func (s *stubCmdSynthesisProvider) Synthesize(_ context.Context, _ string) (stri
s.called = true
return "synthesis verdict", nil
}
-
-// TestComposeMultiAgentSinks exercises the sink-composition helper directly
-// with explicit isTTY/canPrompt values, so we get real coverage of the TTY
-// branch without depending on os.Stdout being a terminal during `go test`.
-func TestComposeMultiAgentSinks(t *testing.T) {
- t.Parallel()
-
- provider := &stubCmdSynthesisProvider{}
- noopCancel := func() {}
-
- tests := []struct {
- name string
- isTTY bool
- canPrompt bool
- provider review.SynthesisProvider
- wantTUI bool
- wantDump bool
- wantSynth bool
- wantTotal int
- }{
- {
- name: "non-tty omits tui and synth",
- isTTY: false,
- canPrompt: false,
- provider: provider,
- wantDump: true,
- wantTotal: 1,
- },
- {
- name: "tty with provider and prompt appends synth",
- isTTY: true,
- canPrompt: true,
- provider: provider,
- wantTUI: true,
- wantDump: true,
- wantSynth: true,
- wantTotal: 3,
- },
- {
- name: "tty without provider skips synth",
- isTTY: true,
- canPrompt: true,
- provider: nil,
- wantTUI: true,
- wantDump: true,
- wantTotal: 2,
- },
- {
- name: "tty without prompt skips synth even with provider",
- isTTY: true,
- canPrompt: false,
- provider: provider,
- wantTUI: true,
- wantDump: true,
- wantTotal: 2,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- sinks := review.ExposedComposeMultiAgentSinks(review.SinkComposeInputs{
- Out: &bytes.Buffer{},
- IsTTY: tt.isTTY,
- CanPrompt: tt.canPrompt,
- AgentNames: []string{"a", "b"},
- CancelRun: noopCancel,
- SynthesisProvider: tt.provider,
- })
- if got := len(sinks); got != tt.wantTotal {
- t.Fatalf("len(sinks)=%d, want %d", got, tt.wantTotal)
- }
- _, hasTUI := review.ExposedFindTUISink(sinks)
- if hasTUI != tt.wantTUI {
- t.Errorf("findTUISink found=%v, want %v", hasTUI, tt.wantTUI)
- }
- var hasDump, hasSynth bool
- for _, s := range sinks {
- switch s.(type) {
- case review.DumpSink:
- hasDump = true
- case review.SynthesisSink:
- hasSynth = true
- }
- }
- if hasDump != tt.wantDump {
- t.Errorf("DumpSink present=%v, want %v", hasDump, tt.wantDump)
- }
- if hasSynth != tt.wantSynth {
- t.Errorf("SynthesisSink present=%v, want %v", hasSynth, tt.wantSynth)
- }
- })
- }
-}
-
-func TestComposeSingleAgentSinks(t *testing.T) {
- t.Parallel()
-
- noopCancel := func() {}
-
- tests := []struct {
- name string
- isTTY bool
- canPrompt bool
- wantTUI bool
- wantDump bool
- wantTotal int
- wantOutput string
- }{
- {
- name: "non-tty prints running line and uses dump only",
- wantDump: true,
- wantTotal: 1,
- wantOutput: "Running review with agent-a...",
- },
- {
- name: "tty uses tui and dump",
- isTTY: true,
- canPrompt: true,
- wantTUI: true,
- wantDump: true,
- wantTotal: 2,
- },
- {
- name: "tty without prompt falls back to running line",
- isTTY: true,
- canPrompt: false,
- wantDump: true,
- wantTotal: 1,
- wantOutput: "Running review with agent-a...",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- out := &bytes.Buffer{}
- sinks := review.ExposedComposeSingleAgentSinks(review.SingleAgentSinkComposeInputs{
- Out: out,
- IsTTY: tt.isTTY,
- CanPrompt: tt.canPrompt,
- AgentName: "agent-a",
- CancelRun: noopCancel,
- })
- if got := len(sinks); got != tt.wantTotal {
- t.Fatalf("len(sinks)=%d, want %d", got, tt.wantTotal)
- }
- _, hasTUI := review.ExposedFindTUISink(sinks)
- if hasTUI != tt.wantTUI {
- t.Errorf("findTUISink found=%v, want %v", hasTUI, tt.wantTUI)
- }
- var hasDump, hasSynth bool
- for _, s := range sinks {
- switch s.(type) {
- case review.DumpSink:
- hasDump = true
- case review.SynthesisSink:
- hasSynth = true
- }
- }
- if hasDump != tt.wantDump {
- t.Errorf("DumpSink present=%v, want %v", hasDump, tt.wantDump)
- }
- if hasSynth {
- t.Error("SynthesisSink should not be present for single-agent reviews")
- }
- if tt.wantOutput != "" && !strings.Contains(out.String(), tt.wantOutput) {
- t.Errorf("output missing %q:\n%s", tt.wantOutput, out.String())
- }
- if tt.wantOutput == "" && out.Len() != 0 {
- t.Errorf("expected no pre-run output, got:\n%s", out.String())
- }
- })
- }
-}
-
-func TestComposeSinks_TUIWritersRunBeforePostRunWriters(t *testing.T) {
- t.Parallel()
- provider := &stubSynthesisProvider{}
-
- multi := review.ExposedComposeMultiAgentSinks(review.SinkComposeInputs{
- Out: &bytes.Buffer{},
- IsTTY: true,
- CanPrompt: true,
- AgentNames: []string{"a", "b"},
- CancelRun: func() {},
- SynthesisProvider: provider,
- })
- if len(multi) != 3 {
- t.Fatalf("multi sinks len = %d, want 3", len(multi))
- }
- if _, ok := multi[0].(*review.TUISink); !ok {
- t.Fatalf("multi sink[0] = %T, want *TUISink", multi[0])
- }
- if _, ok := multi[1].(review.DumpSink); !ok {
- t.Fatalf("multi sink[1] = %T, want DumpSink", multi[1])
- }
- if _, ok := multi[2].(review.SynthesisSink); !ok {
- t.Fatalf("multi sink[2] = %T, want SynthesisSink", multi[2])
- }
-
- single := review.ExposedComposeSingleAgentSinks(review.SingleAgentSinkComposeInputs{
- Out: &bytes.Buffer{},
- IsTTY: true,
- CanPrompt: true,
- AgentName: "a",
- CancelRun: func() {},
- })
- if len(single) != 2 {
- t.Fatalf("single sinks len = %d, want 2", len(single))
- }
- if _, ok := single[0].(*review.TUISink); !ok {
- t.Fatalf("single sink[0] = %T, want *TUISink", single[0])
- }
- if _, ok := single[1].(review.DumpSink); !ok {
- t.Fatalf("single sink[1] = %T, want DumpSink", single[1])
- }
-}
-
-// TestFindTUISink_NoTUIInSlice covers the not-found path so the caller's
-// `if tuiSink, ok := findTUISink(sinks); ok` branch is exercised in both
-// directions.
-func TestFindTUISink_NoTUIInSlice(t *testing.T) {
- t.Parallel()
- sinks := []reviewtypes.Sink{review.DumpSink{W: &bytes.Buffer{}}}
- if tui, ok := review.ExposedFindTUISink(sinks); ok || tui != nil {
- t.Errorf("findTUISink on dump-only slice returned (%v, %v); want (nil, false)", tui, ok)
- }
-}
-
-// TestDispatchFork_SynthesisSinkNilProviderNoComposition verifies that when
-// deps.SynthesisProvider is nil, the command runs without panicking and does
-// not attempt to synthesize (no synthesis output appears).
-func TestDispatchFork_SynthesisSinkNilProviderNoComposition(t *testing.T) {
- setupCmdTestRepo(t)
-
- if err := review.SaveReviewConfig(context.Background(), map[string]settings.ReviewConfig{
- "agent-a": {Prompt: "review"},
- "agent-b": {Prompt: "review"},
- }); err != nil {
- t.Fatal(err)
- }
-
- multiPickerFn := func(_ context.Context, eligible []review.AgentChoice) (review.PickedAgents, error) {
- names := make([]string, 0, len(eligible))
- for _, e := range eligible {
- names = append(names, e.Name)
- }
- return review.PickedAgents{Names: names, PerRun: ""}, nil
- }
-
- installed := []types.AgentName{"agent-a", "agent-b"}
- deps := newDispatchTestDeps(t, installed, []string{"agent-a", "agent-b"}, multiPickerFn, nil)
- deps.SynthesisProvider = nil // explicitly nil — synthesis unavailable
-
- buf := &bytes.Buffer{}
- cmd := review.NewCommand(deps)
- cmd.SetOut(buf)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- // No synthesis output expected.
- if strings.Contains(buf.String(), "synthesis") {
- t.Errorf("no synthesis output expected when provider is nil, got: %s", buf.String())
- }
-}
-
-// TestDispatchFork_SingleAgentNoSynthesis verifies that the single-agent path
-// never invokes synthesis (synthesis is multi-agent only). We set a provider
-// but use a single launchable agent; the command should complete without
-// calling the synthesis provider.
-func TestDispatchFork_SingleAgentNoSynthesis(t *testing.T) {
- setupCmdTestRepo(t)
- installHooksForCmdTest(t, "cursor")
-
- if err := review.SaveReviewConfig(context.Background(), map[string]settings.ReviewConfig{
- "cursor": {Prompt: "review"},
- }); err != nil {
- t.Fatal(err)
- }
-
- provider := &stubCmdSynthesisProvider{}
-
- // cursor is installed but not launchable (ReviewerFor returns nil).
- installed := []types.AgentName{"cursor"}
- deps := newDispatchTestDeps(t, installed, nil /* no launchable */, nil, nil)
- deps.SynthesisProvider = provider
-
- buf := &bytes.Buffer{}
- cmd := review.NewCommand(deps)
- cmd.SetOut(buf)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if provider.called {
- t.Error("synthesis provider should NOT be called on single-agent path")
- }
-}
diff --git a/cli/rewind.go b/cli/rewind.go
index 9f733f9..1fcca3f 100644
--- a/cli/rewind.go
+++ b/cli/rewind.go
@@ -7,11 +7,9 @@ import (
"io"
"log/slog"
"os"
- "os/exec"
"path/filepath"
"strings"
"time"
- "unicode"
agentpkg "github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/external"
@@ -22,7 +20,6 @@ import (
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/strategy"
- "github.com/GrayCodeAI/trace/cli/transcript"
"charm.land/huh/v2"
"github.com/go-git/go-git/v6"
@@ -810,481 +807,3 @@ func restoreSessionTranscriptFromShadow(ctx context.Context, commitHash, metadat
}
return sessionID, nil
}
-
-// restoreTaskCheckpointTranscript restores a truncated transcript for a task checkpoint.
-// Uses GetTaskCheckpointTranscript to fetch the transcript from the strategy.
-//
-// NOTE: The transcript parsing/truncation/writing pipeline (transcript.ParseFromBytes,
-// TruncateTranscriptAtUUID, writeTranscript) assumes Claude's JSONL format.
-// This is acceptable because task checkpoints are currently only created by Claude Code's
-// PostToolUse hook. If other agents gain sub-agent support, this will need a
-// format-aware refactor (agent-specific parsing, truncation, and serialization).
-func restoreTaskCheckpointTranscript(ctx context.Context, w io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, sessionID, checkpointUUID string, agent agentpkg.Agent) error {
- // Get transcript content from strategy
- content, err := start.GetTaskCheckpointTranscript(ctx, point)
- if err != nil {
- return fmt.Errorf("failed to get task checkpoint transcript: %w", err)
- }
-
- // Parse the transcript
- parsed, err := transcript.ParseFromBytes(content)
- if err != nil {
- return fmt.Errorf("failed to parse transcript: %w", err)
- }
-
- // Truncate at checkpoint UUID
- truncated := TruncateTranscriptAtUUID(parsed, checkpointUUID)
-
- sessionFile, err := resolveTranscriptPath(ctx, sessionID, agent)
- if err != nil {
- return err
- }
-
- // Ensure parent directory exists
- if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil {
- return fmt.Errorf("failed to create agent session directory: %w", err)
- }
-
- fmt.Fprintf(w, "Writing truncated transcript to: %s\n", sessionFile)
-
- if err := writeTranscript(sessionFile, truncated); err != nil {
- return fmt.Errorf("failed to write truncated transcript: %w", err)
- }
-
- return nil
-}
-
-// handleLogsOnlyRewindInteractive handles rewind for logs-only points with a sub-choice menu.
-func handleLogsOnlyRewindInteractive(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
- var action string
-
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewSelect[string]().
- Title("Logs-only point: "+shortID).
- Description("This commit has session logs but no checkpoint state. Choose an action:").
- Options(
- huh.NewOption("Restore logs only (keep current files)", "logs"),
- huh.NewOption("Checkout commit (detached HEAD, for viewing)", "checkout"),
- huh.NewOption("Reset branch to this commit (destructive!)", "reset"),
- huh.NewOption("Cancel", "cancel"),
- ).
- Value(&action),
- ),
- )
-
- if err := form.Run(); err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- return nil
- }
- return fmt.Errorf("action selection failed: %w", err)
- }
-
- switch action {
- case "logs":
- return handleLogsOnlyRestore(ctx, w, errW, start, point)
- case "checkout":
- return handleLogsOnlyCheckout(ctx, w, errW, start, point, shortID)
- case "reset":
- return handleLogsOnlyReset(ctx, w, errW, start, point, shortID)
- case "cancel":
- fmt.Fprintln(w, "Rewind cancelled.")
- return nil
- }
-
- return nil
-}
-
-// handleLogsOnlyRestore restores only the session logs without changing files.
-func handleLogsOnlyRestore(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error {
- // Resolve agent once for use throughout
- agent, err := getAgent(point.Agent)
- if err != nil {
- return fmt.Errorf("failed to get agent: %w", err)
- }
-
- // Initialize logging context with agent from checkpoint
- logCtx := logging.WithComponent(ctx, "rewind")
- logCtx = logging.WithAgent(logCtx, agent.Name())
-
- logging.Debug(
- logCtx, "logs-only restore started",
- slog.String("checkpoint_id", point.ID),
- slog.String("session_id", point.SessionID),
- )
-
- // Restore logs
- sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
- if err != nil {
- logging.Error(
- logCtx, "logs-only restore failed",
- slog.String("checkpoint_id", point.ID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to restore logs: %w", err)
- }
-
- logging.Debug(
- logCtx, "logs-only restore completed",
- slog.String("checkpoint_id", point.ID),
- )
-
- // Show resume commands for all sessions
- fmt.Fprintln(w, "✓ Restored session logs.")
- printMultiSessionResumeCommands(w, errW, sessions)
- return nil
-}
-
-// handleLogsOnlyCheckout restores logs and checks out the commit (detached HEAD).
-func handleLogsOnlyCheckout(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
- // Resolve agent once for use throughout
- agent, err := getAgent(point.Agent)
- if err != nil {
- return fmt.Errorf("failed to get agent: %w", err)
- }
-
- // Initialize logging context with agent from checkpoint
- logCtx := logging.WithComponent(ctx, "rewind")
- logCtx = logging.WithAgent(logCtx, agent.Name())
-
- logging.Debug(
- logCtx, "logs-only checkout started",
- slog.String("checkpoint_id", point.ID),
- slog.String("session_id", point.SessionID),
- )
-
- sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
- if err != nil {
- logging.Error(
- logCtx, "logs-only checkout failed during log restoration",
- slog.String("checkpoint_id", point.ID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to restore logs: %w", err)
- }
-
- // Show warning about detached HEAD
- var confirm bool
- confirmForm := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title("Create detached HEAD?").
- Description("This will checkout the commit directly. You'll be in 'detached HEAD' state.\nAny uncommitted changes will be lost!").
- Value(&confirm),
- ),
- )
-
- if err := confirmForm.Run(); err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- return nil
- }
- return fmt.Errorf("confirmation failed: %w", err)
- }
-
- if !confirm {
- fmt.Fprintln(w, "Checkout cancelled. Session logs were still restored.")
- printMultiSessionResumeCommands(w, errW, sessions)
- return nil
- }
-
- // Perform git checkout
- if err := CheckoutBranch(ctx, point.ID); err != nil {
- logging.Error(
- logCtx, "logs-only checkout failed during git checkout",
- slog.String("checkpoint_id", point.ID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to checkout commit: %w", err)
- }
-
- logging.Debug(
- logCtx, "logs-only checkout completed",
- slog.String("checkpoint_id", point.ID),
- )
-
- fmt.Fprintf(w, "✓ Checked out %s (detached HEAD).\n", shortID)
- printMultiSessionResumeCommands(w, errW, sessions)
- return nil
-}
-
-// handleLogsOnlyReset restores logs and resets the branch to the commit (destructive).
-func handleLogsOnlyReset(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
- // Resolve agent once for use throughout
- agent, agentErr := getAgent(point.Agent)
- if agentErr != nil {
- return fmt.Errorf("failed to get agent: %w", agentErr)
- }
-
- // Initialize logging context with agent from checkpoint
- logCtx := logging.WithComponent(ctx, "rewind")
- logCtx = logging.WithAgent(logCtx, agent.Name())
-
- logging.Debug(
- logCtx, "logs-only reset (interactive) started",
- slog.String("checkpoint_id", point.ID),
- slog.String("session_id", point.SessionID),
- )
-
- sessions, restoreErr := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
- if restoreErr != nil {
- logging.Error(
- logCtx, "logs-only reset failed during log restoration",
- slog.String("checkpoint_id", point.ID),
- slog.String("error", restoreErr.Error()),
- )
- return fmt.Errorf("failed to restore logs: %w", restoreErr)
- }
-
- // Get current HEAD before reset (for recovery message)
- currentHead, err := getCurrentHeadHash(ctx)
- if err != nil {
- // Non-fatal - just won't show recovery message
- currentHead = ""
- }
-
- // Get detailed uncommitted changes warning from strategy
- var uncommittedWarning string
- if _, warn, err := start.CanRewind(ctx); err == nil {
- uncommittedWarning = warn
- }
-
- // Check for safety issues
- warnings, err := checkResetSafety(ctx, point.ID, uncommittedWarning)
- if err != nil {
- return fmt.Errorf("failed to check reset safety: %w", err)
- }
-
- // Build confirmation message based on warnings
- var confirmTitle, confirmDesc string
- if len(warnings) > 0 {
- confirmTitle = "⚠️ Reset branch with warnings?"
- confirmDesc = "WARNING - the following issues were detected:\n" +
- strings.Join(warnings, "\n") +
- "\n\nThis will move your branch to " + shortID + " and DISCARD commits after it!"
- } else {
- confirmTitle = "Reset branch to " + shortID + "?"
- confirmDesc = "This will move your branch pointer to this commit.\nCommits after this point will be orphaned (but recoverable via reflog)."
- }
-
- var confirm bool
- confirmForm := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title(confirmTitle).
- Description(confirmDesc).
- Value(&confirm),
- ),
- )
-
- if err := confirmForm.Run(); err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- return nil
- }
- return fmt.Errorf("confirmation failed: %w", err)
- }
-
- if !confirm {
- fmt.Fprintln(w, "Reset cancelled. Session logs were still restored.")
- printMultiSessionResumeCommands(w, errW, sessions)
- return nil
- }
-
- // Perform git reset --hard
- if err := performGitResetHard(ctx, point.ID); err != nil {
- logging.Error(
- logCtx, "logs-only reset failed during git reset",
- slog.String("checkpoint_id", point.ID),
- slog.String("error", err.Error()),
- )
- return fmt.Errorf("failed to reset branch: %w", err)
- }
-
- logging.Debug(
- logCtx, "logs-only reset (interactive) completed",
- slog.String("checkpoint_id", point.ID),
- )
-
- fmt.Fprintf(w, "✓ Reset branch to %s.\n", shortID)
- printMultiSessionResumeCommands(w, errW, sessions)
-
- // Show recovery instructions
- if currentHead != "" && currentHead != point.ID {
- currentShort := currentHead
- if len(currentShort) > 7 {
- currentShort = currentShort[:7]
- }
- fmt.Fprintf(w, "\nTo undo this reset: git reset --hard %s\n", currentShort)
- }
-
- return nil
-}
-
-// getCurrentHeadHash returns the current HEAD commit hash.
-func getCurrentHeadHash(ctx context.Context) (string, error) {
- repo, err := openRepository(ctx)
- if err != nil {
- return "", err
- }
-
- head, err := repo.Head()
- if err != nil {
- return "", fmt.Errorf("failed to get HEAD: %w", err)
- }
-
- return head.Hash().String(), nil
-}
-
-// checkResetSafety checks for potential issues before a git reset --hard.
-// Returns a list of warning messages (empty if safe to proceed without warnings).
-// If uncommittedChangesWarning is provided, it will be used instead of a generic warning.
-func checkResetSafety(ctx context.Context, targetCommitHash string, uncommittedChangesWarning string) ([]string, error) {
- var warnings []string
-
- repo, err := openRepository(ctx)
- if err != nil {
- return nil, err
- }
-
- // Check for uncommitted changes
- if uncommittedChangesWarning != "" {
- // Use the detailed warning from strategy's CanRewind()
- warnings = append(warnings, uncommittedChangesWarning)
- } else {
- // Fall back to generic check
- worktree, err := repo.Worktree()
- if err != nil {
- return nil, fmt.Errorf("failed to get worktree: %w", err)
- }
-
- status, err := worktree.Status()
- if err != nil {
- return nil, fmt.Errorf("failed to get status: %w", err)
- }
-
- if !status.IsClean() {
- warnings = append(warnings, "• You have uncommitted changes that will be LOST")
- }
- }
-
- // Check if current HEAD is ahead of target (we'd be discarding commits)
- head, err := repo.Head()
- if err != nil {
- return nil, fmt.Errorf("failed to get HEAD: %w", err)
- }
-
- targetHash := plumbing.NewHash(targetCommitHash)
-
- // Count commits between target and HEAD
- commitsAhead, err := countCommitsBetween(repo, targetHash, head.Hash())
- if err != nil {
- // Non-fatal - just can't show commit count
- commitsAhead = -1
- }
-
- if commitsAhead > 0 {
- warnings = append(warnings, fmt.Sprintf("• %d commit(s) after this point will be orphaned", commitsAhead))
- }
-
- return warnings, nil
-}
-
-// countCommitsBetween counts commits between ancestor and descendant.
-// Returns 0 if ancestor == descendant, -1 on error.
-func countCommitsBetween(repo *git.Repository, ancestor, descendant plumbing.Hash) (int, error) {
- if ancestor == descendant {
- return 0, nil
- }
-
- // Walk from descendant back to ancestor
- count := 0
- current := descendant
-
- for count < strategy.MaxCommitTraversalDepth { // Safety limit
- if current == ancestor {
- return count, nil
- }
-
- commit, err := repo.CommitObject(current)
- if err != nil {
- return -1, fmt.Errorf("failed to get commit: %w", err)
- }
-
- if commit.NumParents() == 0 {
- // Reached root without finding ancestor - ancestor not in history
- return -1, nil
- }
-
- count++
- current = commit.ParentHashes[0] // Follow first parent
- }
-
- return -1, nil
-}
-
-// performGitResetHard performs a git reset --hard to the specified commit.
-// Uses the git CLI instead of go-git because go-git's HardReset incorrectly
-// deletes untracked directories (like .trace/) even when they're in .gitignore.
-func performGitResetHard(ctx context.Context, commitHash string) error {
- if strings.HasPrefix(commitHash, "-") {
- return fmt.Errorf("reset failed: invalid commit hash %q", commitHash)
- }
- cmd := exec.CommandContext(ctx, "git", "reset", "--hard", commitHash)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("reset failed: %s: %w", strings.TrimSpace(string(output)), err)
- }
- return nil
-}
-
-// sanitizeForTerminal removes or replaces characters that cause rendering issues
-// in terminal UI components. This includes emojis with skin-tone modifiers and
-// other multi-codepoint characters that confuse width calculations.
-func sanitizeForTerminal(s string) string {
- var result strings.Builder
- result.Grow(len(s))
-
- for _, r := range s {
- // Skip emoji skin tone modifiers (U+1F3FB to U+1F3FF)
- if r >= 0x1F3FB && r <= 0x1F3FF {
- continue
- }
- // Skip zero-width joiners used in emoji sequences
- if r == 0x200D {
- continue
- }
- // Skip variation selectors (U+FE00 to U+FE0F)
- if r >= 0xFE00 && r <= 0xFE0F {
- continue
- }
- // Keep printable characters and common whitespace
- if unicode.IsPrint(r) || r == '\t' || r == '\n' {
- result.WriteRune(r)
- }
- }
-
- return result.String()
-}
-
-// printMultiSessionResumeCommands prints resume commands for restored sessions.
-// Each session may have a different agent, so per-session agent resolution is used.
-func printMultiSessionResumeCommands(w, errW io.Writer, sessions []strategy.RestoredSession) {
- if len(sessions) == 0 {
- return
- }
-
- if len(sessions) > 1 {
- fmt.Fprintf(w, "\n✓ Restored %d sessions. To continue:\n", len(sessions))
- } else {
- fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID)
- fmt.Fprintf(w, "\nTo continue this session:\n")
- }
-
- isMulti := len(sessions) > 1
- for i, sess := range sessions {
- ag, err := strategy.ResolveAgentForRewind(sess.Agent)
- if err != nil {
- fmt.Fprintf(errW, " Warning: could not resolve agent %q for session %s, skipping\n", sess.Agent, sess.SessionID)
- continue
- }
- printSessionCommand(w, ag.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1)
- }
-}
diff --git a/cli/rewind_2.go b/cli/rewind_2.go
new file mode 100644
index 0000000..c7166e7
--- /dev/null
+++ b/cli/rewind_2.go
@@ -0,0 +1,501 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "unicode"
+
+ agentpkg "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/transcript"
+
+ "charm.land/huh/v2"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+// restoreTaskCheckpointTranscript restores a truncated transcript for a task checkpoint.
+// Uses GetTaskCheckpointTranscript to fetch the transcript from the strategy.
+//
+// NOTE: The transcript parsing/truncation/writing pipeline (transcript.ParseFromBytes,
+// TruncateTranscriptAtUUID, writeTranscript) assumes Claude's JSONL format.
+// This is acceptable because task checkpoints are currently only created by Claude Code's
+// PostToolUse hook. If other agents gain sub-agent support, this will need a
+// format-aware refactor (agent-specific parsing, truncation, and serialization).
+func restoreTaskCheckpointTranscript(ctx context.Context, w io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, sessionID, checkpointUUID string, agent agentpkg.Agent) error {
+ // Get transcript content from strategy
+ content, err := start.GetTaskCheckpointTranscript(ctx, point)
+ if err != nil {
+ return fmt.Errorf("failed to get task checkpoint transcript: %w", err)
+ }
+
+ // Parse the transcript
+ parsed, err := transcript.ParseFromBytes(content)
+ if err != nil {
+ return fmt.Errorf("failed to parse transcript: %w", err)
+ }
+
+ // Truncate at checkpoint UUID
+ truncated := TruncateTranscriptAtUUID(parsed, checkpointUUID)
+
+ sessionFile, err := resolveTranscriptPath(ctx, sessionID, agent)
+ if err != nil {
+ return err
+ }
+
+ // Ensure parent directory exists
+ if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil {
+ return fmt.Errorf("failed to create agent session directory: %w", err)
+ }
+
+ fmt.Fprintf(w, "Writing truncated transcript to: %s\n", sessionFile)
+
+ if err := writeTranscript(sessionFile, truncated); err != nil {
+ return fmt.Errorf("failed to write truncated transcript: %w", err)
+ }
+
+ return nil
+}
+
+// handleLogsOnlyRewindInteractive handles rewind for logs-only points with a sub-choice menu.
+func handleLogsOnlyRewindInteractive(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
+ var action string
+
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Logs-only point: "+shortID).
+ Description("This commit has session logs but no checkpoint state. Choose an action:").
+ Options(
+ huh.NewOption("Restore logs only (keep current files)", "logs"),
+ huh.NewOption("Checkout commit (detached HEAD, for viewing)", "checkout"),
+ huh.NewOption("Reset branch to this commit (destructive!)", "reset"),
+ huh.NewOption("Cancel", "cancel"),
+ ).
+ Value(&action),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return nil
+ }
+ return fmt.Errorf("action selection failed: %w", err)
+ }
+
+ switch action {
+ case "logs":
+ return handleLogsOnlyRestore(ctx, w, errW, start, point)
+ case "checkout":
+ return handleLogsOnlyCheckout(ctx, w, errW, start, point, shortID)
+ case "reset":
+ return handleLogsOnlyReset(ctx, w, errW, start, point, shortID)
+ case "cancel":
+ fmt.Fprintln(w, "Rewind cancelled.")
+ return nil
+ }
+
+ return nil
+}
+
+// handleLogsOnlyRestore restores only the session logs without changing files.
+func handleLogsOnlyRestore(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error {
+ // Resolve agent once for use throughout
+ agent, err := getAgent(point.Agent)
+ if err != nil {
+ return fmt.Errorf("failed to get agent: %w", err)
+ }
+
+ // Initialize logging context with agent from checkpoint
+ logCtx := logging.WithComponent(ctx, "rewind")
+ logCtx = logging.WithAgent(logCtx, agent.Name())
+
+ logging.Debug(
+ logCtx, "logs-only restore started",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("session_id", point.SessionID),
+ )
+
+ // Restore logs
+ sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
+ if err != nil {
+ logging.Error(
+ logCtx, "logs-only restore failed",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to restore logs: %w", err)
+ }
+
+ logging.Debug(
+ logCtx, "logs-only restore completed",
+ slog.String("checkpoint_id", point.ID),
+ )
+
+ // Show resume commands for all sessions
+ fmt.Fprintln(w, "✓ Restored session logs.")
+ printMultiSessionResumeCommands(w, errW, sessions)
+ return nil
+}
+
+// handleLogsOnlyCheckout restores logs and checks out the commit (detached HEAD).
+func handleLogsOnlyCheckout(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
+ // Resolve agent once for use throughout
+ agent, err := getAgent(point.Agent)
+ if err != nil {
+ return fmt.Errorf("failed to get agent: %w", err)
+ }
+
+ // Initialize logging context with agent from checkpoint
+ logCtx := logging.WithComponent(ctx, "rewind")
+ logCtx = logging.WithAgent(logCtx, agent.Name())
+
+ logging.Debug(
+ logCtx, "logs-only checkout started",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("session_id", point.SessionID),
+ )
+
+ sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
+ if err != nil {
+ logging.Error(
+ logCtx, "logs-only checkout failed during log restoration",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to restore logs: %w", err)
+ }
+
+ // Show warning about detached HEAD
+ var confirm bool
+ confirmForm := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Create detached HEAD?").
+ Description("This will checkout the commit directly. You'll be in 'detached HEAD' state.\nAny uncommitted changes will be lost!").
+ Value(&confirm),
+ ),
+ )
+
+ if err := confirmForm.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return nil
+ }
+ return fmt.Errorf("confirmation failed: %w", err)
+ }
+
+ if !confirm {
+ fmt.Fprintln(w, "Checkout cancelled. Session logs were still restored.")
+ printMultiSessionResumeCommands(w, errW, sessions)
+ return nil
+ }
+
+ // Perform git checkout
+ if err := CheckoutBranch(ctx, point.ID); err != nil {
+ logging.Error(
+ logCtx, "logs-only checkout failed during git checkout",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to checkout commit: %w", err)
+ }
+
+ logging.Debug(
+ logCtx, "logs-only checkout completed",
+ slog.String("checkpoint_id", point.ID),
+ )
+
+ fmt.Fprintf(w, "✓ Checked out %s (detached HEAD).\n", shortID)
+ printMultiSessionResumeCommands(w, errW, sessions)
+ return nil
+}
+
+// handleLogsOnlyReset restores logs and resets the branch to the commit (destructive).
+func handleLogsOnlyReset(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error {
+ // Resolve agent once for use throughout
+ agent, agentErr := getAgent(point.Agent)
+ if agentErr != nil {
+ return fmt.Errorf("failed to get agent: %w", agentErr)
+ }
+
+ // Initialize logging context with agent from checkpoint
+ logCtx := logging.WithComponent(ctx, "rewind")
+ logCtx = logging.WithAgent(logCtx, agent.Name())
+
+ logging.Debug(
+ logCtx, "logs-only reset (interactive) started",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("session_id", point.SessionID),
+ )
+
+ sessions, restoreErr := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind
+ if restoreErr != nil {
+ logging.Error(
+ logCtx, "logs-only reset failed during log restoration",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("error", restoreErr.Error()),
+ )
+ return fmt.Errorf("failed to restore logs: %w", restoreErr)
+ }
+
+ // Get current HEAD before reset (for recovery message)
+ currentHead, err := getCurrentHeadHash(ctx)
+ if err != nil {
+ // Non-fatal - just won't show recovery message
+ currentHead = ""
+ }
+
+ // Get detailed uncommitted changes warning from strategy
+ var uncommittedWarning string
+ if _, warn, err := start.CanRewind(ctx); err == nil {
+ uncommittedWarning = warn
+ }
+
+ // Check for safety issues
+ warnings, err := checkResetSafety(ctx, point.ID, uncommittedWarning)
+ if err != nil {
+ return fmt.Errorf("failed to check reset safety: %w", err)
+ }
+
+ // Build confirmation message based on warnings
+ var confirmTitle, confirmDesc string
+ if len(warnings) > 0 {
+ confirmTitle = "⚠️ Reset branch with warnings?"
+ confirmDesc = "WARNING - the following issues were detected:\n" +
+ strings.Join(warnings, "\n") +
+ "\n\nThis will move your branch to " + shortID + " and DISCARD commits after it!"
+ } else {
+ confirmTitle = "Reset branch to " + shortID + "?"
+ confirmDesc = "This will move your branch pointer to this commit.\nCommits after this point will be orphaned (but recoverable via reflog)."
+ }
+
+ var confirm bool
+ confirmForm := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title(confirmTitle).
+ Description(confirmDesc).
+ Value(&confirm),
+ ),
+ )
+
+ if err := confirmForm.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return nil
+ }
+ return fmt.Errorf("confirmation failed: %w", err)
+ }
+
+ if !confirm {
+ fmt.Fprintln(w, "Reset cancelled. Session logs were still restored.")
+ printMultiSessionResumeCommands(w, errW, sessions)
+ return nil
+ }
+
+ // Perform git reset --hard
+ if err := performGitResetHard(ctx, point.ID); err != nil {
+ logging.Error(
+ logCtx, "logs-only reset failed during git reset",
+ slog.String("checkpoint_id", point.ID),
+ slog.String("error", err.Error()),
+ )
+ return fmt.Errorf("failed to reset branch: %w", err)
+ }
+
+ logging.Debug(
+ logCtx, "logs-only reset (interactive) completed",
+ slog.String("checkpoint_id", point.ID),
+ )
+
+ fmt.Fprintf(w, "✓ Reset branch to %s.\n", shortID)
+ printMultiSessionResumeCommands(w, errW, sessions)
+
+ // Show recovery instructions
+ if currentHead != "" && currentHead != point.ID {
+ currentShort := currentHead
+ if len(currentShort) > 7 {
+ currentShort = currentShort[:7]
+ }
+ fmt.Fprintf(w, "\nTo undo this reset: git reset --hard %s\n", currentShort)
+ }
+
+ return nil
+}
+
+// getCurrentHeadHash returns the current HEAD commit hash.
+func getCurrentHeadHash(ctx context.Context) (string, error) {
+ repo, err := openRepository(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ head, err := repo.Head()
+ if err != nil {
+ return "", fmt.Errorf("failed to get HEAD: %w", err)
+ }
+
+ return head.Hash().String(), nil
+}
+
+// checkResetSafety checks for potential issues before a git reset --hard.
+// Returns a list of warning messages (empty if safe to proceed without warnings).
+// If uncommittedChangesWarning is provided, it will be used instead of a generic warning.
+func checkResetSafety(ctx context.Context, targetCommitHash string, uncommittedChangesWarning string) ([]string, error) {
+ var warnings []string
+
+ repo, err := openRepository(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check for uncommitted changes
+ if uncommittedChangesWarning != "" {
+ // Use the detailed warning from strategy's CanRewind()
+ warnings = append(warnings, uncommittedChangesWarning)
+ } else {
+ // Fall back to generic check
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get worktree: %w", err)
+ }
+
+ status, err := worktree.Status()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get status: %w", err)
+ }
+
+ if !status.IsClean() {
+ warnings = append(warnings, "• You have uncommitted changes that will be LOST")
+ }
+ }
+
+ // Check if current HEAD is ahead of target (we'd be discarding commits)
+ head, err := repo.Head()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get HEAD: %w", err)
+ }
+
+ targetHash := plumbing.NewHash(targetCommitHash)
+
+ // Count commits between target and HEAD
+ commitsAhead, err := countCommitsBetween(repo, targetHash, head.Hash())
+ if err != nil {
+ // Non-fatal - just can't show commit count
+ commitsAhead = -1
+ }
+
+ if commitsAhead > 0 {
+ warnings = append(warnings, fmt.Sprintf("• %d commit(s) after this point will be orphaned", commitsAhead))
+ }
+
+ return warnings, nil
+}
+
+// countCommitsBetween counts commits between ancestor and descendant.
+// Returns 0 if ancestor == descendant, -1 on error.
+func countCommitsBetween(repo *git.Repository, ancestor, descendant plumbing.Hash) (int, error) {
+ if ancestor == descendant {
+ return 0, nil
+ }
+
+ // Walk from descendant back to ancestor
+ count := 0
+ current := descendant
+
+ for count < strategy.MaxCommitTraversalDepth { // Safety limit
+ if current == ancestor {
+ return count, nil
+ }
+
+ commit, err := repo.CommitObject(current)
+ if err != nil {
+ return -1, fmt.Errorf("failed to get commit: %w", err)
+ }
+
+ if commit.NumParents() == 0 {
+ // Reached root without finding ancestor - ancestor not in history
+ return -1, nil
+ }
+
+ count++
+ current = commit.ParentHashes[0] // Follow first parent
+ }
+
+ return -1, nil
+}
+
+// performGitResetHard performs a git reset --hard to the specified commit.
+// Uses the git CLI instead of go-git because go-git's HardReset incorrectly
+// deletes untracked directories (like .trace/) even when they're in .gitignore.
+func performGitResetHard(ctx context.Context, commitHash string) error {
+ if strings.HasPrefix(commitHash, "-") {
+ return fmt.Errorf("reset failed: invalid commit hash %q", commitHash)
+ }
+ cmd := exec.CommandContext(ctx, "git", "reset", "--hard", commitHash)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("reset failed: %s: %w", strings.TrimSpace(string(output)), err)
+ }
+ return nil
+}
+
+// sanitizeForTerminal removes or replaces characters that cause rendering issues
+// in terminal UI components. This includes emojis with skin-tone modifiers and
+// other multi-codepoint characters that confuse width calculations.
+func sanitizeForTerminal(s string) string {
+ var result strings.Builder
+ result.Grow(len(s))
+
+ for _, r := range s {
+ // Skip emoji skin tone modifiers (U+1F3FB to U+1F3FF)
+ if r >= 0x1F3FB && r <= 0x1F3FF {
+ continue
+ }
+ // Skip zero-width joiners used in emoji sequences
+ if r == 0x200D {
+ continue
+ }
+ // Skip variation selectors (U+FE00 to U+FE0F)
+ if r >= 0xFE00 && r <= 0xFE0F {
+ continue
+ }
+ // Keep printable characters and common whitespace
+ if unicode.IsPrint(r) || r == '\t' || r == '\n' {
+ result.WriteRune(r)
+ }
+ }
+
+ return result.String()
+}
+
+// printMultiSessionResumeCommands prints resume commands for restored sessions.
+// Each session may have a different agent, so per-session agent resolution is used.
+func printMultiSessionResumeCommands(w, errW io.Writer, sessions []strategy.RestoredSession) {
+ if len(sessions) == 0 {
+ return
+ }
+
+ if len(sessions) > 1 {
+ fmt.Fprintf(w, "\n✓ Restored %d sessions. To continue:\n", len(sessions))
+ } else {
+ fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID)
+ fmt.Fprintf(w, "\nTo continue this session:\n")
+ }
+
+ isMulti := len(sessions) > 1
+ for i, sess := range sessions {
+ ag, err := strategy.ResolveAgentForRewind(sess.Agent)
+ if err != nil {
+ fmt.Fprintf(errW, " Warning: could not resolve agent %q for session %s, skipping\n", sess.Agent, sess.SessionID)
+ continue
+ }
+ printSessionCommand(w, ag.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1)
+ }
+}
diff --git a/cli/search_tui.go b/cli/search_tui.go
index 2da393f..6d30aef 100644
--- a/cli/search_tui.go
+++ b/cli/search_tui.go
@@ -3,7 +3,6 @@ package cli
import (
"context"
"fmt"
- "io"
"strings"
"time"
@@ -11,9 +10,6 @@ import (
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
- glamour "charm.land/glamour/v2"
- "charm.land/glamour/v2/ansi"
- glamourstyles "charm.land/glamour/v2/styles"
"charm.land/lipgloss/v2"
"github.com/GrayCodeAI/trace/cli/search"
"github.com/GrayCodeAI/trace/cli/stringutil"
@@ -813,198 +809,3 @@ type columnLayout struct {
prompt int
author int
}
-
-// computeColumns calculates column widths from terminal width.
-func computeColumns(width int) columnLayout {
- const (
- ageWidth = 10
- idWidth = 12
- repoMin = 10
- authorWidth = 14
- gaps = 5 // spaces between columns
- )
-
- remaining := width - ageWidth - idWidth - authorWidth - gaps
- if remaining < 20 {
- remaining = 20
- }
-
- branchWidth := max(remaining*18/100, 8)
- repoWidth := max(remaining*18/100, repoMin)
- promptWidth := remaining - branchWidth - repoWidth
- if promptWidth < 12 {
- reclaim := 12 - promptWidth
- repoWidth = max(repoWidth-reclaim, repoMin)
- promptWidth = remaining - branchWidth - repoWidth
- }
-
- return columnLayout{
- age: ageWidth,
- id: idWidth,
- branch: branchWidth,
- repo: repoWidth,
- prompt: promptWidth,
- author: authorWidth,
- }
-}
-
-// ─── Formatting Helpers ──────────────────────────────────────────────────────
-
-// formatSearchAge parses an RFC3339 timestamp and returns a relative time string.
-func formatSearchAge(createdAt string) string {
- t, err := time.Parse(time.RFC3339, createdAt)
- if err != nil {
- return createdAt
- }
- return timeAgo(t)
-}
-
-// formatCommit renders commit SHA + message, handling nil pointers.
-func formatCommit(sha, message *string) string {
- s := derefStr(sha, "—")
- if sha != nil && len(*sha) > 7 {
- s = (*sha)[:7]
- }
- msg := derefStr(message, "")
- if msg != "" {
- s += " " + msg
- }
- return s
-}
-
-// derefStr returns the dereferenced string pointer, or fallback if nil.
-func derefStr(s *string, fallback string) string {
- if s == nil {
- return fallback
- }
- return *s
-}
-
-// ─── Snippet Markdown ────────────────────────────────────────────────────────
-
-// renderSnippetMarkdown renders a search snippet as markdown using glamour v2.
-// It is used in the full-screen checkpoint detail view where the snippet has
-// room to breathe; the inline detail card keeps plain word-wrapping. On any
-// renderer error or impractically narrow widths it falls back to wrapText.
-//
-// dark must be detected before bubbletea owns the terminal — querying termenv
-// inside the Update loop races against bubbletea's stdin reader and stalls.
-//
-// A fresh TermRenderer is built per call. *TermRenderer carries shared mutable
-// state via ansi.RenderContext.blockStack, so caching the renderer would
-// require serialising every Render call; construction is cheap (just goldmark
-// + ANSI option setup, no chroma init unless a fenced code block forces it),
-// so we just rebuild and avoid the concurrency hazard altogether.
-func renderSnippetMarkdown(snippet string, width int, dark bool) string {
- if width < 20 {
- return wrapText(snippet, width)
- }
- renderer, err := glamour.NewTermRenderer(
- glamour.WithStyles(snippetMarkdownStyles(dark)),
- glamour.WithWordWrap(width),
- glamour.WithPreservedNewLines(),
- )
- if err != nil {
- return wrapText(snippet, width)
- }
- rendered, err := renderer.Render(snippet)
- if err != nil {
- return wrapText(snippet, width)
- }
- return strings.TrimRight(rendered, "\n")
-}
-
-// snippetMarkdownStyles returns a glamour style config tailored for inline
-// snippets. Foreground colours are nilled across every text-bearing element
-// so the snippet inherits the terminal's default foreground colour. ANSI
-// palette numbers like "234" embedded in glamour's stock styles get remapped
-// by terminal themes and produce unreadable colours on cream / Solarized
-// backgrounds — letting the terminal pick the colour avoids that entirely.
-//
-// IMPORTANT: this function copies a package-level glamourstyles var by value,
-// then re-assigns its pointer fields. *Re-assigning* (`= nil`, `= &x`) is
-// safe — it rebinds the local field. *Dereferencing* through the pointer
-// (`*s.Document.Color = "x"`) would mutate the shared global and pollute
-// every other glamour caller in the process. Don't do that.
-func snippetMarkdownStyles(dark bool) ansi.StyleConfig {
- var s ansi.StyleConfig
- if dark {
- s = glamourstyles.DarkStyleConfig
- } else {
- s = glamourstyles.LightStyleConfig
- }
- zero := uint(0)
- s.Document.Margin = &zero
- s.Document.BlockPrefix = ""
- s.Document.BlockSuffix = ""
-
- // Null foreground on every primitive that contributes to flowing text so
- // nothing relies on theme-remappable ANSI palette numbers. Code/CodeBlock
- // keep their styling because BackgroundColor is enough to differentiate
- // them visually.
- s.Document.Color = nil
- s.Paragraph.Color = nil
- s.Text.Color = nil
- s.BlockQuote.Color = nil
- s.Strong.Color = nil
- s.Emph.Color = nil
- s.Strikethrough.Color = nil
- s.Heading.Color = nil
- s.H1.Color = nil
- s.H2.Color = nil
- s.H3.Color = nil
- s.H4.Color = nil
- s.H5.Color = nil
- s.H6.Color = nil
- s.Item.Color = nil
- s.Enumeration.Color = nil
- s.List.Color = nil
-
- // Links are the one place we *want* a colour: an underline alone is easy
- // to miss inline. Use an explicit hex so it survives theme remapping.
- linkColor := searchAccentBlue
- s.Link.Color = &linkColor
- s.LinkText.Color = &linkColor
-
- return s
-}
-
-// ─── Static Fallback ─────────────────────────────────────────────────────────
-
-// renderSearchStatic writes a non-interactive table for accessible mode.
-func renderSearchStatic(w io.Writer, results []search.Result, query string, total int, styles statusStyles) {
- fmt.Fprintf(w, "Found %d checkpoints matching %q\n\n", total, query)
-
- cols := computeColumns(styles.width)
-
- fmt.Fprintf(
- w, "%-*s %-*s %-*s %-*s %-*s %-*s\n",
- cols.age, "AGE",
- cols.id, "ID",
- cols.branch, "BRANCH",
- cols.repo, "REPO",
- cols.prompt, "PROMPT",
- cols.author, "AUTHOR",
- )
-
- for _, r := range results {
- age := formatSearchAge(r.Data.CreatedAt)
- id := stringutil.TruncateRunes(r.Data.ID, cols.id, "")
- branch := stringutil.TruncateRunes(r.Data.Branch, cols.branch, "...")
- repo := stringutil.TruncateRunes(r.Data.Org+"/"+r.Data.Repo, cols.repo, "...")
- prompt := stringutil.TruncateRunes(
- stringutil.CollapseWhitespace(r.Data.Prompt), cols.prompt, "...",
- )
- author := stringutil.TruncateRunes(derefStr(r.Data.AuthorUsername, r.Data.Author), cols.author, "...")
-
- fmt.Fprintf(
- w, "%-*s %-*s %-*s %-*s %-*s %-*s\n",
- cols.age, age,
- cols.id, id,
- cols.branch, branch,
- cols.repo, repo,
- cols.prompt, prompt,
- cols.author, author,
- )
- }
-}
diff --git a/cli/search_tui_2.go b/cli/search_tui_2.go
new file mode 100644
index 0000000..835482e
--- /dev/null
+++ b/cli/search_tui_2.go
@@ -0,0 +1,209 @@
+package cli
+
+import (
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ glamour "charm.land/glamour/v2"
+ "charm.land/glamour/v2/ansi"
+ glamourstyles "charm.land/glamour/v2/styles"
+ "github.com/GrayCodeAI/trace/cli/search"
+ "github.com/GrayCodeAI/trace/cli/stringutil"
+)
+
+// computeColumns calculates column widths from terminal width.
+func computeColumns(width int) columnLayout {
+ const (
+ ageWidth = 10
+ idWidth = 12
+ repoMin = 10
+ authorWidth = 14
+ gaps = 5 // spaces between columns
+ )
+
+ remaining := width - ageWidth - idWidth - authorWidth - gaps
+ if remaining < 20 {
+ remaining = 20
+ }
+
+ branchWidth := max(remaining*18/100, 8)
+ repoWidth := max(remaining*18/100, repoMin)
+ promptWidth := remaining - branchWidth - repoWidth
+ if promptWidth < 12 {
+ reclaim := 12 - promptWidth
+ repoWidth = max(repoWidth-reclaim, repoMin)
+ promptWidth = remaining - branchWidth - repoWidth
+ }
+
+ return columnLayout{
+ age: ageWidth,
+ id: idWidth,
+ branch: branchWidth,
+ repo: repoWidth,
+ prompt: promptWidth,
+ author: authorWidth,
+ }
+}
+
+// ─── Formatting Helpers ──────────────────────────────────────────────────────
+
+// formatSearchAge parses an RFC3339 timestamp and returns a relative time string.
+func formatSearchAge(createdAt string) string {
+ t, err := time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return createdAt
+ }
+ return timeAgo(t)
+}
+
+// formatCommit renders commit SHA + message, handling nil pointers.
+func formatCommit(sha, message *string) string {
+ s := derefStr(sha, "—")
+ if sha != nil && len(*sha) > 7 {
+ s = (*sha)[:7]
+ }
+ msg := derefStr(message, "")
+ if msg != "" {
+ s += " " + msg
+ }
+ return s
+}
+
+// derefStr returns the dereferenced string pointer, or fallback if nil.
+func derefStr(s *string, fallback string) string {
+ if s == nil {
+ return fallback
+ }
+ return *s
+}
+
+// ─── Snippet Markdown ────────────────────────────────────────────────────────
+
+// renderSnippetMarkdown renders a search snippet as markdown using glamour v2.
+// It is used in the full-screen checkpoint detail view where the snippet has
+// room to breathe; the inline detail card keeps plain word-wrapping. On any
+// renderer error or impractically narrow widths it falls back to wrapText.
+//
+// dark must be detected before bubbletea owns the terminal — querying termenv
+// inside the Update loop races against bubbletea's stdin reader and stalls.
+//
+// A fresh TermRenderer is built per call. *TermRenderer carries shared mutable
+// state via ansi.RenderContext.blockStack, so caching the renderer would
+// require serialising every Render call; construction is cheap (just goldmark
+// + ANSI option setup, no chroma init unless a fenced code block forces it),
+// so we just rebuild and avoid the concurrency hazard altogether.
+func renderSnippetMarkdown(snippet string, width int, dark bool) string {
+ if width < 20 {
+ return wrapText(snippet, width)
+ }
+ renderer, err := glamour.NewTermRenderer(
+ glamour.WithStyles(snippetMarkdownStyles(dark)),
+ glamour.WithWordWrap(width),
+ glamour.WithPreservedNewLines(),
+ )
+ if err != nil {
+ return wrapText(snippet, width)
+ }
+ rendered, err := renderer.Render(snippet)
+ if err != nil {
+ return wrapText(snippet, width)
+ }
+ return strings.TrimRight(rendered, "\n")
+}
+
+// snippetMarkdownStyles returns a glamour style config tailored for inline
+// snippets. Foreground colours are nilled across every text-bearing element
+// so the snippet inherits the terminal's default foreground colour. ANSI
+// palette numbers like "234" embedded in glamour's stock styles get remapped
+// by terminal themes and produce unreadable colours on cream / Solarized
+// backgrounds — letting the terminal pick the colour avoids that entirely.
+//
+// IMPORTANT: this function copies a package-level glamourstyles var by value,
+// then re-assigns its pointer fields. *Re-assigning* (`= nil`, `= &x`) is
+// safe — it rebinds the local field. *Dereferencing* through the pointer
+// (`*s.Document.Color = "x"`) would mutate the shared global and pollute
+// every other glamour caller in the process. Don't do that.
+func snippetMarkdownStyles(dark bool) ansi.StyleConfig {
+ var s ansi.StyleConfig
+ if dark {
+ s = glamourstyles.DarkStyleConfig
+ } else {
+ s = glamourstyles.LightStyleConfig
+ }
+ zero := uint(0)
+ s.Document.Margin = &zero
+ s.Document.BlockPrefix = ""
+ s.Document.BlockSuffix = ""
+
+ // Null foreground on every primitive that contributes to flowing text so
+ // nothing relies on theme-remappable ANSI palette numbers. Code/CodeBlock
+ // keep their styling because BackgroundColor is enough to differentiate
+ // them visually.
+ s.Document.Color = nil
+ s.Paragraph.Color = nil
+ s.Text.Color = nil
+ s.BlockQuote.Color = nil
+ s.Strong.Color = nil
+ s.Emph.Color = nil
+ s.Strikethrough.Color = nil
+ s.Heading.Color = nil
+ s.H1.Color = nil
+ s.H2.Color = nil
+ s.H3.Color = nil
+ s.H4.Color = nil
+ s.H5.Color = nil
+ s.H6.Color = nil
+ s.Item.Color = nil
+ s.Enumeration.Color = nil
+ s.List.Color = nil
+
+ // Links are the one place we *want* a colour: an underline alone is easy
+ // to miss inline. Use an explicit hex so it survives theme remapping.
+ linkColor := searchAccentBlue
+ s.Link.Color = &linkColor
+ s.LinkText.Color = &linkColor
+
+ return s
+}
+
+// ─── Static Fallback ─────────────────────────────────────────────────────────
+
+// renderSearchStatic writes a non-interactive table for accessible mode.
+func renderSearchStatic(w io.Writer, results []search.Result, query string, total int, styles statusStyles) {
+ fmt.Fprintf(w, "Found %d checkpoints matching %q\n\n", total, query)
+
+ cols := computeColumns(styles.width)
+
+ fmt.Fprintf(
+ w, "%-*s %-*s %-*s %-*s %-*s %-*s\n",
+ cols.age, "AGE",
+ cols.id, "ID",
+ cols.branch, "BRANCH",
+ cols.repo, "REPO",
+ cols.prompt, "PROMPT",
+ cols.author, "AUTHOR",
+ )
+
+ for _, r := range results {
+ age := formatSearchAge(r.Data.CreatedAt)
+ id := stringutil.TruncateRunes(r.Data.ID, cols.id, "")
+ branch := stringutil.TruncateRunes(r.Data.Branch, cols.branch, "...")
+ repo := stringutil.TruncateRunes(r.Data.Org+"/"+r.Data.Repo, cols.repo, "...")
+ prompt := stringutil.TruncateRunes(
+ stringutil.CollapseWhitespace(r.Data.Prompt), cols.prompt, "...",
+ )
+ author := stringutil.TruncateRunes(derefStr(r.Data.AuthorUsername, r.Data.Author), cols.author, "...")
+
+ fmt.Fprintf(
+ w, "%-*s %-*s %-*s %-*s %-*s %-*s\n",
+ cols.age, age,
+ cols.id, id,
+ cols.branch, branch,
+ cols.repo, repo,
+ cols.prompt, prompt,
+ cols.author, author,
+ )
+ }
+}
diff --git a/cli/search_tui_2_test.go b/cli/search_tui_2_test.go
new file mode 100644
index 0000000..c41686b
--- /dev/null
+++ b/cli/search_tui_2_test.go
@@ -0,0 +1,382 @@
+package cli
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/GrayCodeAI/trace/cli/search"
+)
+
+func TestSearchModel_SelectedResult(t *testing.T) {
+ t.Parallel()
+
+ m := testModel()
+ r := m.selectedResult()
+ if r == nil {
+ t.Fatal("selectedResult() = nil, want first result")
+ return
+ }
+ if r.Data.ID != "a3b2c4d5e6f7" {
+ t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "a3b2c4d5e6f7")
+ }
+
+ // Move cursor to second result
+ m.cursor = 1
+ r = m.selectedResult()
+ if r == nil {
+ t.Fatal("selectedResult() at cursor 1 = nil")
+ return
+ }
+ if r.Data.ID != "d5e6f789ab01" {
+ t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "d5e6f789ab01")
+ }
+
+ // Out-of-range cursor returns nil
+ m.cursor = 99
+ if got := m.selectedResult(); got != nil {
+ t.Errorf("selectedResult() at cursor 99 = %v, want nil", got)
+ }
+}
+
+func TestSearchModel_PageNavigation(t *testing.T) {
+ t.Parallel()
+
+ // Create model with 30 results (2 pages)
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r"}
+ results := make([]search.Result, 30)
+ for i := range results {
+ results[i] = search.Result{Data: search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
+ }
+ m := newSearchModel(results, "q", 30, cfg, ss)
+
+ if m.page != 0 {
+ t.Fatalf("initial page = %d, want 0", m.page)
+ }
+
+ // Navigate to next page
+ m = updateModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"})
+ if m.page != 1 {
+ t.Errorf("after 'n': page = %d, want 1", m.page)
+ }
+ if m.cursor != 0 {
+ t.Errorf("after 'n': cursor = %d, want 0 (reset)", m.cursor)
+ }
+
+ // Can't go past last page
+ m = updateModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"})
+ if m.page != 1 {
+ t.Errorf("after 'n' on last page: page = %d, want 1", m.page)
+ }
+
+ // Navigate back
+ m = updateModel(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"})
+ if m.page != 0 {
+ t.Errorf("after 'p': page = %d, want 0", m.page)
+ }
+
+ // Can't go before first page
+ m = updateModel(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"})
+ if m.page != 0 {
+ t.Errorf("after 'p' on first page: page = %d, want 0", m.page)
+ }
+}
+
+func TestSearchModel_NewSearchClearsFilters(t *testing.T) {
+ t.Parallel()
+
+ // Create model with startup filters
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{
+ ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25,
+ Author: "alice", Date: "week",
+ }
+ m := newSearchModel(testResults(), "auth", 2, cfg, ss)
+
+ // Enter search mode
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+
+ // Type a query without filters
+ m.input.SetValue(newQuery)
+
+ // Press enter — should trigger search with cleared filters
+ updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if !m.loading {
+ t.Fatal("expected loading to be true")
+ }
+ if cmd == nil {
+ t.Fatal("expected a search command")
+ }
+
+ // searchCfg should be updated with the new query and cleared filters,
+ // so that fetchMoreResults uses the correct config for page 2+.
+ if m.searchCfg.Author != "" {
+ t.Errorf("searchCfg.Author should be cleared, got %q", m.searchCfg.Author)
+ }
+ if m.searchCfg.Date != "" {
+ t.Errorf("searchCfg.Date should be cleared, got %q", m.searchCfg.Date)
+ }
+ if got := m.searchCfg.Repos; len(got) != 0 {
+ t.Errorf("searchCfg.Repos should be cleared, got %v", got)
+ }
+ if m.searchCfg.Query != newQuery {
+ t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
+ }
+}
+
+func TestSearchModel_FetchMoreError(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{}
+ m := newSearchModel(make([]search.Result, 25), "q", 50, cfg, ss)
+ m.fetchingMore = true
+
+ m = updateModel(t, m, searchMoreResultsMsg{err: errTestSearch})
+
+ if m.fetchingMore {
+ t.Error("fetchingMore should be false after error")
+ }
+ if m.searchErr == "" {
+ t.Error("searchErr should be set after fetch-more error")
+ }
+ if len(m.results) != 25 {
+ t.Errorf("results should be unchanged, got %d", len(m.results))
+ }
+}
+
+func TestSearchModel_FetchMoreEmpty_CapsTotal(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{}
+ m := newSearchModel(make([]search.Result, 25), "q", 100, cfg, ss)
+
+ if m.totalPages() != 4 {
+ t.Fatalf("initial totalPages = %d, want 4", m.totalPages())
+ }
+
+ // Simulate API returning empty results (exhausted)
+ m = updateModel(t, m, searchMoreResultsMsg{results: nil})
+
+ if m.total != 25 {
+ t.Errorf("total should be capped to loaded results (25), got %d", m.total)
+ }
+ if m.totalPages() != 1 {
+ t.Errorf("totalPages should be 1 after cap, got %d", m.totalPages())
+ }
+}
+
+func TestSearchModel_ViewFetchingMore(t *testing.T) {
+ t.Parallel()
+
+ // Model with 25 loaded results but on page 2 (no data) while fetching
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{}
+ m := initTestViewport(newSearchModel(make([]search.Result, 25), "q", 50, cfg, ss))
+ m.page = 1
+ m.fetchingMore = true
+ m = m.refreshBrowseContent()
+
+ view := m.View().Content
+ if !strings.Contains(view, "Loading more results...") {
+ t.Error("view should show loading message when fetchingMore and page has no data")
+ }
+}
+
+func TestSearchModel_NewSearchPersistsFilters(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25}
+ m := newSearchModel(testResults(), "old", 2, cfg, ss)
+
+ // Enter search mode and type query with filters
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+ m.input.SetValue(newQuery + " author:bob date:month")
+
+ updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if m.searchCfg.Query != newQuery {
+ t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
+ }
+ if m.searchCfg.Author != "bob" {
+ t.Errorf("searchCfg.Author = %q, want %q", m.searchCfg.Author, "bob")
+ }
+ if m.searchCfg.Date != "month" {
+ t.Errorf("searchCfg.Date = %q, want %q", m.searchCfg.Date, "month")
+ }
+}
+
+func TestSearchModel_NewSearchPersistsRepoFilters(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{
+ ServiceURL: "http://test",
+ Owner: "default-owner",
+ Repo: "default-repo",
+ Limit: 25,
+ }
+ m := newSearchModel(testResults(), "old", 2, cfg, ss)
+
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+ m.input.SetValue(newQuery + " repo:GrayCodeAI/trace.io")
+
+ updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if m.searchCfg.Query != newQuery {
+ t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
+ }
+ if got := m.searchCfg.Repos; len(got) != 1 || got[0] != "GrayCodeAI/trace.io" {
+ t.Errorf("searchCfg.Repos = %v, want %v", got, []string{"GrayCodeAI/trace.io"})
+ }
+}
+
+func TestSearchModel_NewSearchClearsExplicitRepoFilters(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{
+ ServiceURL: "http://test",
+ Owner: "default-owner",
+ Repo: "default-repo",
+ Limit: 25,
+ Repos: []string{"GrayCodeAI/trace.io"},
+ }
+ m := newSearchModel(testResults(), "auth", 2, cfg, ss)
+
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+ m.input.SetValue(newQuery)
+
+ updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if got := m.searchCfg.Repos; len(got) != 0 {
+ t.Errorf("searchCfg.Repos = %v, want empty explicit repo overrides", got)
+ }
+ if m.searchCfg.Owner != "default-owner" || m.searchCfg.Repo != "default-repo" {
+ t.Errorf("default repo scope changed unexpectedly: %s/%s", m.searchCfg.Owner, m.searchCfg.Repo)
+ }
+}
+
+func TestSearchModel_NewSearchAllReposFilter(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{
+ ServiceURL: "http://test",
+ Owner: "default-owner",
+ Repo: "default-repo",
+ Limit: 25,
+ }
+ m := newSearchModel(testResults(), "old", 2, cfg, ss)
+
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+ m.input.SetValue(newQuery + " repo:*")
+
+ updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if got := m.searchCfg.Repos; len(got) != 1 || got[0] != search.AllReposFilter {
+ t.Errorf("searchCfg.Repos = %v, want %v", got, []string{search.AllReposFilter})
+ }
+}
+
+func TestSearchModel_NewSearchRejectsMultipleExplicitRepos(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{
+ ServiceURL: "http://test",
+ Owner: "default-owner",
+ Repo: "default-repo",
+ Limit: 25,
+ }
+ m := newSearchModel(testResults(), "old", 2, cfg, ss)
+
+ m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
+ m.input.SetValue(newQuery + " repo:GrayCodeAI/trace.io,GrayCodeAI/cli")
+
+ updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m, ok := updated.(searchModel)
+ if !ok {
+ t.Fatalf("Update returned %T, want searchModel", updated)
+ }
+
+ if cmd != nil {
+ t.Fatal("expected no search command on invalid multi-repo input")
+ }
+ if m.mode != modeSearch {
+ t.Errorf("mode = %d, want modeSearch", m.mode)
+ }
+ if m.searchErr != "only one explicit repo filter is currently supported" {
+ t.Errorf("searchErr = %q", m.searchErr)
+ }
+}
+
+func TestSearchModel_ApiPageInitialization(t *testing.T) {
+ t.Parallel()
+
+ ss := statusStyles{colorEnabled: false, width: 100}
+ cfg := search.Config{}
+
+ // With results: apiPage = 1
+ withResults := newSearchModel(testResults(), "q", 2, cfg, ss)
+ if withResults.apiPage != 1 {
+ t.Errorf("apiPage with results = %d, want 1", withResults.apiPage)
+ }
+
+ // Without results: apiPage = 0
+ noResults := newSearchModel(nil, "", 0, cfg, ss)
+ if noResults.apiPage != 0 {
+ t.Errorf("apiPage without results = %d, want 0", noResults.apiPage)
+ }
+}
+
+func TestComputeColumns(t *testing.T) {
+ t.Parallel()
+
+ cols := computeColumns(100)
+ if cols.age != 10 {
+ t.Errorf("age width = %d, want 10", cols.age)
+ }
+ if cols.id != 12 {
+ t.Errorf("id width = %d, want 12", cols.id)
+ }
+ if cols.repo < 10 {
+ t.Errorf("repo width = %d, want >= 10", cols.repo)
+ }
+ if cols.author != 14 {
+ t.Errorf("author width = %d, want 14", cols.author)
+ }
+
+ cols = computeColumns(40)
+ if cols.branch < 8 {
+ t.Errorf("branch width on narrow terminal = %d, want >= 8", cols.branch)
+ }
+ if cols.repo < 10 {
+ t.Errorf("repo width on narrow terminal = %d, want >= 10", cols.repo)
+ }
+}
diff --git a/cli/search_tui_test.go b/cli/search_tui_test.go
index 4a3d3cd..d3ec8cc 100644
--- a/cli/search_tui_test.go
+++ b/cli/search_tui_test.go
@@ -796,375 +796,3 @@ func TestSearchModel_NewSearchResetsApiPage(t *testing.T) {
t.Error("fetchingMore should be false after new search")
}
}
-
-func TestSearchModel_SelectedResult(t *testing.T) {
- t.Parallel()
-
- m := testModel()
- r := m.selectedResult()
- if r == nil {
- t.Fatal("selectedResult() = nil, want first result")
- return
- }
- if r.Data.ID != "a3b2c4d5e6f7" {
- t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "a3b2c4d5e6f7")
- }
-
- // Move cursor to second result
- m.cursor = 1
- r = m.selectedResult()
- if r == nil {
- t.Fatal("selectedResult() at cursor 1 = nil")
- return
- }
- if r.Data.ID != "d5e6f789ab01" {
- t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "d5e6f789ab01")
- }
-
- // Out-of-range cursor returns nil
- m.cursor = 99
- if got := m.selectedResult(); got != nil {
- t.Errorf("selectedResult() at cursor 99 = %v, want nil", got)
- }
-}
-
-func TestSearchModel_PageNavigation(t *testing.T) {
- t.Parallel()
-
- // Create model with 30 results (2 pages)
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r"}
- results := make([]search.Result, 30)
- for i := range results {
- results[i] = search.Result{Data: search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
- }
- m := newSearchModel(results, "q", 30, cfg, ss)
-
- if m.page != 0 {
- t.Fatalf("initial page = %d, want 0", m.page)
- }
-
- // Navigate to next page
- m = updateModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"})
- if m.page != 1 {
- t.Errorf("after 'n': page = %d, want 1", m.page)
- }
- if m.cursor != 0 {
- t.Errorf("after 'n': cursor = %d, want 0 (reset)", m.cursor)
- }
-
- // Can't go past last page
- m = updateModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"})
- if m.page != 1 {
- t.Errorf("after 'n' on last page: page = %d, want 1", m.page)
- }
-
- // Navigate back
- m = updateModel(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"})
- if m.page != 0 {
- t.Errorf("after 'p': page = %d, want 0", m.page)
- }
-
- // Can't go before first page
- m = updateModel(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"})
- if m.page != 0 {
- t.Errorf("after 'p' on first page: page = %d, want 0", m.page)
- }
-}
-
-func TestSearchModel_NewSearchClearsFilters(t *testing.T) {
- t.Parallel()
-
- // Create model with startup filters
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{
- ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25,
- Author: "alice", Date: "week",
- }
- m := newSearchModel(testResults(), "auth", 2, cfg, ss)
-
- // Enter search mode
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
-
- // Type a query without filters
- m.input.SetValue(newQuery)
-
- // Press enter — should trigger search with cleared filters
- updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if !m.loading {
- t.Fatal("expected loading to be true")
- }
- if cmd == nil {
- t.Fatal("expected a search command")
- }
-
- // searchCfg should be updated with the new query and cleared filters,
- // so that fetchMoreResults uses the correct config for page 2+.
- if m.searchCfg.Author != "" {
- t.Errorf("searchCfg.Author should be cleared, got %q", m.searchCfg.Author)
- }
- if m.searchCfg.Date != "" {
- t.Errorf("searchCfg.Date should be cleared, got %q", m.searchCfg.Date)
- }
- if got := m.searchCfg.Repos; len(got) != 0 {
- t.Errorf("searchCfg.Repos should be cleared, got %v", got)
- }
- if m.searchCfg.Query != newQuery {
- t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
- }
-}
-
-func TestSearchModel_FetchMoreError(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{}
- m := newSearchModel(make([]search.Result, 25), "q", 50, cfg, ss)
- m.fetchingMore = true
-
- m = updateModel(t, m, searchMoreResultsMsg{err: errTestSearch})
-
- if m.fetchingMore {
- t.Error("fetchingMore should be false after error")
- }
- if m.searchErr == "" {
- t.Error("searchErr should be set after fetch-more error")
- }
- if len(m.results) != 25 {
- t.Errorf("results should be unchanged, got %d", len(m.results))
- }
-}
-
-func TestSearchModel_FetchMoreEmpty_CapsTotal(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{}
- m := newSearchModel(make([]search.Result, 25), "q", 100, cfg, ss)
-
- if m.totalPages() != 4 {
- t.Fatalf("initial totalPages = %d, want 4", m.totalPages())
- }
-
- // Simulate API returning empty results (exhausted)
- m = updateModel(t, m, searchMoreResultsMsg{results: nil})
-
- if m.total != 25 {
- t.Errorf("total should be capped to loaded results (25), got %d", m.total)
- }
- if m.totalPages() != 1 {
- t.Errorf("totalPages should be 1 after cap, got %d", m.totalPages())
- }
-}
-
-func TestSearchModel_ViewFetchingMore(t *testing.T) {
- t.Parallel()
-
- // Model with 25 loaded results but on page 2 (no data) while fetching
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{}
- m := initTestViewport(newSearchModel(make([]search.Result, 25), "q", 50, cfg, ss))
- m.page = 1
- m.fetchingMore = true
- m = m.refreshBrowseContent()
-
- view := m.View().Content
- if !strings.Contains(view, "Loading more results...") {
- t.Error("view should show loading message when fetchingMore and page has no data")
- }
-}
-
-func TestSearchModel_NewSearchPersistsFilters(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25}
- m := newSearchModel(testResults(), "old", 2, cfg, ss)
-
- // Enter search mode and type query with filters
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
- m.input.SetValue(newQuery + " author:bob date:month")
-
- updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if m.searchCfg.Query != newQuery {
- t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
- }
- if m.searchCfg.Author != "bob" {
- t.Errorf("searchCfg.Author = %q, want %q", m.searchCfg.Author, "bob")
- }
- if m.searchCfg.Date != "month" {
- t.Errorf("searchCfg.Date = %q, want %q", m.searchCfg.Date, "month")
- }
-}
-
-func TestSearchModel_NewSearchPersistsRepoFilters(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{
- ServiceURL: "http://test",
- Owner: "default-owner",
- Repo: "default-repo",
- Limit: 25,
- }
- m := newSearchModel(testResults(), "old", 2, cfg, ss)
-
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
- m.input.SetValue(newQuery + " repo:GrayCodeAI/trace.io")
-
- updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if m.searchCfg.Query != newQuery {
- t.Errorf("searchCfg.Query = %q, want %q", m.searchCfg.Query, newQuery)
- }
- if got := m.searchCfg.Repos; len(got) != 1 || got[0] != "GrayCodeAI/trace.io" {
- t.Errorf("searchCfg.Repos = %v, want %v", got, []string{"GrayCodeAI/trace.io"})
- }
-}
-
-func TestSearchModel_NewSearchClearsExplicitRepoFilters(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{
- ServiceURL: "http://test",
- Owner: "default-owner",
- Repo: "default-repo",
- Limit: 25,
- Repos: []string{"GrayCodeAI/trace.io"},
- }
- m := newSearchModel(testResults(), "auth", 2, cfg, ss)
-
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
- m.input.SetValue(newQuery)
-
- updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if got := m.searchCfg.Repos; len(got) != 0 {
- t.Errorf("searchCfg.Repos = %v, want empty explicit repo overrides", got)
- }
- if m.searchCfg.Owner != "default-owner" || m.searchCfg.Repo != "default-repo" {
- t.Errorf("default repo scope changed unexpectedly: %s/%s", m.searchCfg.Owner, m.searchCfg.Repo)
- }
-}
-
-func TestSearchModel_NewSearchAllReposFilter(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{
- ServiceURL: "http://test",
- Owner: "default-owner",
- Repo: "default-repo",
- Limit: 25,
- }
- m := newSearchModel(testResults(), "old", 2, cfg, ss)
-
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
- m.input.SetValue(newQuery + " repo:*")
-
- updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if got := m.searchCfg.Repos; len(got) != 1 || got[0] != search.AllReposFilter {
- t.Errorf("searchCfg.Repos = %v, want %v", got, []string{search.AllReposFilter})
- }
-}
-
-func TestSearchModel_NewSearchRejectsMultipleExplicitRepos(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{
- ServiceURL: "http://test",
- Owner: "default-owner",
- Repo: "default-repo",
- Limit: 25,
- }
- m := newSearchModel(testResults(), "old", 2, cfg, ss)
-
- m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
- m.input.SetValue(newQuery + " repo:GrayCodeAI/trace.io,GrayCodeAI/cli")
-
- updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m, ok := updated.(searchModel)
- if !ok {
- t.Fatalf("Update returned %T, want searchModel", updated)
- }
-
- if cmd != nil {
- t.Fatal("expected no search command on invalid multi-repo input")
- }
- if m.mode != modeSearch {
- t.Errorf("mode = %d, want modeSearch", m.mode)
- }
- if m.searchErr != "only one explicit repo filter is currently supported" {
- t.Errorf("searchErr = %q", m.searchErr)
- }
-}
-
-func TestSearchModel_ApiPageInitialization(t *testing.T) {
- t.Parallel()
-
- ss := statusStyles{colorEnabled: false, width: 100}
- cfg := search.Config{}
-
- // With results: apiPage = 1
- withResults := newSearchModel(testResults(), "q", 2, cfg, ss)
- if withResults.apiPage != 1 {
- t.Errorf("apiPage with results = %d, want 1", withResults.apiPage)
- }
-
- // Without results: apiPage = 0
- noResults := newSearchModel(nil, "", 0, cfg, ss)
- if noResults.apiPage != 0 {
- t.Errorf("apiPage without results = %d, want 0", noResults.apiPage)
- }
-}
-
-func TestComputeColumns(t *testing.T) {
- t.Parallel()
-
- cols := computeColumns(100)
- if cols.age != 10 {
- t.Errorf("age width = %d, want 10", cols.age)
- }
- if cols.id != 12 {
- t.Errorf("id width = %d, want 12", cols.id)
- }
- if cols.repo < 10 {
- t.Errorf("repo width = %d, want >= 10", cols.repo)
- }
- if cols.author != 14 {
- t.Errorf("author width = %d, want 14", cols.author)
- }
-
- cols = computeColumns(40)
- if cols.branch < 8 {
- t.Errorf("branch width on narrow terminal = %d, want >= 8", cols.branch)
- }
- if cols.repo < 10 {
- t.Errorf("repo width on narrow terminal = %d, want >= 10", cols.repo)
- }
-}
diff --git a/cli/sessions_2_test.go b/cli/sessions_2_test.go
new file mode 100644
index 0000000..b915429
--- /dev/null
+++ b/cli/sessions_2_test.go
@@ -0,0 +1,217 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+)
+
+func TestInfoCmd_JSONOutput(t *testing.T) {
+ setupStopTestRepo(t)
+
+ ctx := context.Background()
+
+ state := makeSessionState("test-info-json", session.PhaseIdle)
+ state.AgentType = testAgentClaude
+ state.ModelName = "claude-opus-4-6[1m]"
+ state.WorktreeID = "my-feature"
+ state.StepCount = 2
+ state.LastCheckpointID = testCheckpointID
+ state.TokenUsage = &agent.TokenUsage{
+ InputTokens: 100,
+ CacheReadTokens: 5000,
+ OutputTokens: 500,
+ }
+ state.LastPrompt = testPromptFixLogin
+ state.FilesTouched = []string{"auth.go"}
+
+ if err := strategy.SaveSessionState(ctx, state); err != nil {
+ t.Fatalf("SaveSessionState() error = %v", err)
+ }
+
+ cmd := newInfoCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"test-info-json", "--json"})
+
+ if err := cmd.ExecuteContext(ctx); err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("expected valid JSON, got parse error: %v\noutput: %s", err, stdout.String())
+ }
+
+ if result["session_id"] != "test-info-json" {
+ t.Errorf("expected session_id 'test-info-json', got: %v", result["session_id"])
+ }
+ if result["agent"] != testAgentClaude {
+ t.Errorf("expected agent %q, got: %v", testAgentClaude, result["agent"])
+ }
+ if result["status"] != "idle" {
+ t.Errorf("expected status 'idle', got: %v", result["status"])
+ }
+ if result["last_checkpoint_id"] != testCheckpointID {
+ t.Errorf("expected last_checkpoint_id %q, got: %v", testCheckpointID, result["last_checkpoint_id"])
+ }
+
+ tokens, ok := result["tokens"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("expected tokens object, got: %T", result["tokens"])
+ }
+ total, ok := tokens["total"].(float64)
+ if !ok {
+ t.Fatalf("expected total to be float64, got: %T", tokens["total"])
+ }
+ if total != 5600 {
+ t.Errorf("expected total tokens 5600, got: %v", total)
+ }
+}
+
+func TestInfoCmd_EndedSession(t *testing.T) {
+ setupStopTestRepo(t)
+
+ ctx := context.Background()
+ endedAt := time.Now().Add(-24 * time.Hour)
+
+ state := makeSessionState("test-info-ended", session.PhaseEnded)
+ state.EndedAt = &endedAt
+ state.AgentType = testAgentClaude
+ state.StepCount = 1
+ state.LastCheckpointID = "b79b35cd956d"
+
+ if err := strategy.SaveSessionState(ctx, state); err != nil {
+ t.Fatalf("SaveSessionState() error = %v", err)
+ }
+
+ cmd := newInfoCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetArgs([]string{"test-info-ended"})
+
+ if err := cmd.ExecuteContext(ctx); err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+
+ out := stdout.String()
+ if !strings.Contains(out, "Status: ended") {
+ t.Errorf("expected 'Status: ended' in output, got:\n%s", out)
+ }
+ if !strings.Contains(out, "Ended:") {
+ t.Errorf("expected 'Ended:' line in output, got:\n%s", out)
+ }
+ if !strings.Contains(out, "Checkpoint: b79b35cd956d") {
+ t.Errorf("expected checkpoint ID in output, got:\n%s", out)
+ }
+}
+
+func TestInfoCmd_NotGitRepo(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+ paths.ClearWorktreeRootCache()
+ session.ClearGitCommonDirCache()
+
+ cmd := newSessionsCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"info", "some-id"})
+
+ err := cmd.ExecuteContext(context.Background())
+ if err == nil {
+ t.Fatal("expected error for non-git directory, got nil")
+ }
+ if !strings.Contains(err.Error(), "not a git repository") {
+ t.Errorf("expected 'not a git repository' error, got: %v", err)
+ }
+}
+
+// --- helper function tests ---
+
+func TestSessionWorktreeLabel(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ state *strategy.SessionState
+ expected string
+ }{
+ {
+ name: "uses WorktreeID when set",
+ state: &strategy.SessionState{WorktreeID: "my-feature", WorktreePath: "/some/path/my-feature"},
+ expected: "my-feature",
+ },
+ {
+ name: "falls back to filepath.Base of WorktreePath",
+ state: &strategy.SessionState{WorktreePath: "/Users/dev/repo/.worktrees/feature-branch"},
+ expected: "feature-branch",
+ },
+ {
+ name: "returns (unknown) when both empty",
+ state: &strategy.SessionState{},
+ expected: unknownPlaceholder,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := sessionWorktreeLabel(tt.state)
+ if got != tt.expected {
+ t.Errorf("sessionWorktreeLabel() = %q, want %q", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSessionPhaseLabel(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ state *strategy.SessionState
+ expected string
+ }{
+ {
+ name: "active phase",
+ state: &strategy.SessionState{Phase: session.PhaseActive},
+ expected: "active",
+ },
+ {
+ name: "idle phase",
+ state: &strategy.SessionState{Phase: session.PhaseIdle},
+ expected: "idle",
+ },
+ {
+ name: "ended when EndedAt set",
+ state: &strategy.SessionState{Phase: session.PhaseIdle, EndedAt: &now},
+ expected: "ended",
+ },
+ {
+ name: "empty phase defaults to idle",
+ state: &strategy.SessionState{},
+ expected: "idle",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := sessionPhaseLabel(tt.state)
+ if got != tt.expected {
+ t.Errorf("sessionPhaseLabel() = %q, want %q", got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/cli/sessions_test.go b/cli/sessions_test.go
index 0e5b4cd..d74c951 100644
--- a/cli/sessions_test.go
+++ b/cli/sessions_test.go
@@ -3,7 +3,6 @@ package cli
import (
"bytes"
"context"
- "encoding/json"
"errors"
"strings"
"testing"
@@ -814,205 +813,3 @@ func TestInfoCmd_TextOutput(t *testing.T) {
}
}
}
-
-func TestInfoCmd_JSONOutput(t *testing.T) {
- setupStopTestRepo(t)
-
- ctx := context.Background()
-
- state := makeSessionState("test-info-json", session.PhaseIdle)
- state.AgentType = testAgentClaude
- state.ModelName = "claude-opus-4-6[1m]"
- state.WorktreeID = "my-feature"
- state.StepCount = 2
- state.LastCheckpointID = testCheckpointID
- state.TokenUsage = &agent.TokenUsage{
- InputTokens: 100,
- CacheReadTokens: 5000,
- OutputTokens: 500,
- }
- state.LastPrompt = testPromptFixLogin
- state.FilesTouched = []string{"auth.go"}
-
- if err := strategy.SaveSessionState(ctx, state); err != nil {
- t.Fatalf("SaveSessionState() error = %v", err)
- }
-
- cmd := newInfoCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"test-info-json", "--json"})
-
- if err := cmd.ExecuteContext(ctx); err != nil {
- t.Fatalf("expected no error, got: %v", err)
- }
-
- var result map[string]interface{}
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("expected valid JSON, got parse error: %v\noutput: %s", err, stdout.String())
- }
-
- if result["session_id"] != "test-info-json" {
- t.Errorf("expected session_id 'test-info-json', got: %v", result["session_id"])
- }
- if result["agent"] != testAgentClaude {
- t.Errorf("expected agent %q, got: %v", testAgentClaude, result["agent"])
- }
- if result["status"] != "idle" {
- t.Errorf("expected status 'idle', got: %v", result["status"])
- }
- if result["last_checkpoint_id"] != testCheckpointID {
- t.Errorf("expected last_checkpoint_id %q, got: %v", testCheckpointID, result["last_checkpoint_id"])
- }
-
- tokens, ok := result["tokens"].(map[string]interface{})
- if !ok {
- t.Fatalf("expected tokens object, got: %T", result["tokens"])
- }
- total, ok := tokens["total"].(float64)
- if !ok {
- t.Fatalf("expected total to be float64, got: %T", tokens["total"])
- }
- if total != 5600 {
- t.Errorf("expected total tokens 5600, got: %v", total)
- }
-}
-
-func TestInfoCmd_EndedSession(t *testing.T) {
- setupStopTestRepo(t)
-
- ctx := context.Background()
- endedAt := time.Now().Add(-24 * time.Hour)
-
- state := makeSessionState("test-info-ended", session.PhaseEnded)
- state.EndedAt = &endedAt
- state.AgentType = testAgentClaude
- state.StepCount = 1
- state.LastCheckpointID = "b79b35cd956d"
-
- if err := strategy.SaveSessionState(ctx, state); err != nil {
- t.Fatalf("SaveSessionState() error = %v", err)
- }
-
- cmd := newInfoCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetArgs([]string{"test-info-ended"})
-
- if err := cmd.ExecuteContext(ctx); err != nil {
- t.Fatalf("expected no error, got: %v", err)
- }
-
- out := stdout.String()
- if !strings.Contains(out, "Status: ended") {
- t.Errorf("expected 'Status: ended' in output, got:\n%s", out)
- }
- if !strings.Contains(out, "Ended:") {
- t.Errorf("expected 'Ended:' line in output, got:\n%s", out)
- }
- if !strings.Contains(out, "Checkpoint: b79b35cd956d") {
- t.Errorf("expected checkpoint ID in output, got:\n%s", out)
- }
-}
-
-func TestInfoCmd_NotGitRepo(t *testing.T) {
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
- paths.ClearWorktreeRootCache()
- session.ClearGitCommonDirCache()
-
- cmd := newSessionsCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"info", "some-id"})
-
- err := cmd.ExecuteContext(context.Background())
- if err == nil {
- t.Fatal("expected error for non-git directory, got nil")
- }
- if !strings.Contains(err.Error(), "not a git repository") {
- t.Errorf("expected 'not a git repository' error, got: %v", err)
- }
-}
-
-// --- helper function tests ---
-
-func TestSessionWorktreeLabel(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- state *strategy.SessionState
- expected string
- }{
- {
- name: "uses WorktreeID when set",
- state: &strategy.SessionState{WorktreeID: "my-feature", WorktreePath: "/some/path/my-feature"},
- expected: "my-feature",
- },
- {
- name: "falls back to filepath.Base of WorktreePath",
- state: &strategy.SessionState{WorktreePath: "/Users/dev/repo/.worktrees/feature-branch"},
- expected: "feature-branch",
- },
- {
- name: "returns (unknown) when both empty",
- state: &strategy.SessionState{},
- expected: unknownPlaceholder,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := sessionWorktreeLabel(tt.state)
- if got != tt.expected {
- t.Errorf("sessionWorktreeLabel() = %q, want %q", got, tt.expected)
- }
- })
- }
-}
-
-func TestSessionPhaseLabel(t *testing.T) {
- t.Parallel()
-
- now := time.Now()
-
- tests := []struct {
- name string
- state *strategy.SessionState
- expected string
- }{
- {
- name: "active phase",
- state: &strategy.SessionState{Phase: session.PhaseActive},
- expected: "active",
- },
- {
- name: "idle phase",
- state: &strategy.SessionState{Phase: session.PhaseIdle},
- expected: "idle",
- },
- {
- name: "ended when EndedAt set",
- state: &strategy.SessionState{Phase: session.PhaseIdle, EndedAt: &now},
- expected: "ended",
- },
- {
- name: "empty phase defaults to idle",
- state: &strategy.SessionState{},
- expected: "idle",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := sessionPhaseLabel(tt.state)
- if got != tt.expected {
- t.Errorf("sessionPhaseLabel() = %q, want %q", got, tt.expected)
- }
- })
- }
-}
diff --git a/cli/settings/settings.go b/cli/settings/settings.go
index 3c645e5..18321e0 100644
--- a/cli/settings/settings.go
+++ b/cli/settings/settings.go
@@ -9,15 +9,10 @@ import (
"encoding/json"
"fmt"
"os"
- "path/filepath"
- "strconv"
- "strings"
"sync"
"time"
- "github.com/GrayCodeAI/trace/cli/jsonutil"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/session"
)
const (
@@ -788,530 +783,3 @@ func mergeRedaction(dst *RedactionSettings, data json.RawMessage) error {
}
return nil
}
-
-// mergePIISettings merges PII overrides into existing PIISettings.
-// Only fields present in the override JSON are applied; missing fields
-// are preserved from the base settings.
-func mergePIISettings(dst *PIISettings, data json.RawMessage) error {
- var raw map[string]json.RawMessage
- if err := json.Unmarshal(data, &raw); err != nil {
- return fmt.Errorf("parsing pii: %w", err)
- }
- if v, ok := raw["enabled"]; ok {
- if err := json.Unmarshal(v, &dst.Enabled); err != nil {
- return fmt.Errorf("parsing pii.enabled: %w", err)
- }
- }
- if v, ok := raw["email"]; ok {
- var b bool
- if err := json.Unmarshal(v, &b); err != nil {
- return fmt.Errorf("parsing pii.email: %w", err)
- }
- dst.Email = &b
- }
- if v, ok := raw["phone"]; ok {
- var b bool
- if err := json.Unmarshal(v, &b); err != nil {
- return fmt.Errorf("parsing pii.phone: %w", err)
- }
- dst.Phone = &b
- }
- if v, ok := raw["address"]; ok {
- var b bool
- if err := json.Unmarshal(v, &b); err != nil {
- return fmt.Errorf("parsing pii.address: %w", err)
- }
- dst.Address = &b
- }
- if v, ok := raw["custom_patterns"]; ok {
- var cp map[string]string
- if err := json.Unmarshal(v, &cp); err != nil {
- return fmt.Errorf("parsing pii.custom_patterns: %w", err)
- }
- if dst.CustomPatterns == nil {
- dst.CustomPatterns = cp
- } else {
- for k, val := range cp {
- dst.CustomPatterns[k] = val
- }
- }
- }
- return nil
-}
-
-// IsSetUp returns true if Trace has been set up in the current repository.
-// This checks if .trace/settings.json exists.
-// Use this to avoid creating files/directories in repos where Trace was never enabled.
-func IsSetUp(ctx context.Context) bool {
- settingsFileAbs, err := paths.AbsPath(ctx, TraceSettingsFile)
- if err != nil {
- return false
- }
- _, err = os.Stat(settingsFileAbs)
- return err == nil
-}
-
-// IsSetUpAny returns true if Trace has been set up in the current repository,
-// checking both .trace/settings.json and .trace/settings.local.json.
-// Use this to detect any prior setup, even if only local settings exist.
-func IsSetUpAny(ctx context.Context) bool {
- if IsSetUp(ctx) {
- return true
- }
- localFileAbs, err := paths.AbsPath(ctx, TraceSettingsLocalFile)
- if err != nil {
- return false
- }
- _, err = os.Lstat(localFileAbs)
- return err == nil
-}
-
-// IsSetUpAndEnabled returns true if Trace is both set up and enabled.
-// This checks if .trace/settings.json exists AND has enabled: true.
-// Use this for hooks that should be no-ops when Trace is not active.
-func IsSetUpAndEnabled(ctx context.Context) bool {
- if !IsSetUp(ctx) {
- return false
- }
- s, err := Load(ctx)
- if err != nil {
- return false
- }
- return s.Enabled
-}
-
-// IsCheckpointsV2Enabled checks if checkpoints v2 is enabled in settings.
-// Returns false by default if settings cannot be loaded or the key is missing.
-func IsCheckpointsV2Enabled(ctx context.Context) bool {
- settings, err := Load(ctx)
- if err != nil {
- return false
- }
- return settings.IsCheckpointsV2Enabled()
-}
-
-// CheckpointsVersion returns the configured checkpoints format version, or 1
-// if settings cannot be loaded or the value is unset/invalid.
-func CheckpointsVersion(ctx context.Context) int {
- s, err := Load(ctx)
- if err != nil {
- return 1
- }
- version := s.CheckpointsVersion()
- if s.StrategyOptions != nil {
- if configured, ok := s.StrategyOptions["checkpoints_version"]; ok {
- if _, supported := parseCheckpointsVersion(configured); !supported {
- checkpointsVersionWarningOnce.Do(func() {
- fmt.Fprintf(
- os.Stderr,
- "[trace] unsupported strategy_options.checkpoints_version %v detected in settings. Falling back to the default version (1).\n",
- configured,
- )
- })
- }
- }
- }
- return version
-}
-
-// IsPushV2RefsEnabled checks if pushing v2 refs is enabled in settings.
-// Returns false by default if settings cannot be loaded or flags are missing.
-func IsPushV2RefsEnabled(ctx context.Context) bool {
- s, err := Load(ctx)
- if err != nil {
- return false
- }
- return s.IsPushV2RefsEnabled()
-}
-
-// IsFilteredFetchesEnabled checks if filtered fetches should be used.
-// When enabled, filtered fetches always resolve remote names to URLs first so
-// git does not persist promisor settings onto named remotes in local config.
-// Returns false by default.
-func IsFilteredFetchesEnabled(ctx context.Context) bool {
- s, err := Load(ctx)
- if err != nil {
- return false
- }
- return s.IsFilteredFetchesEnabled()
-}
-
-// IsSummarizeEnabled checks if auto-summarize is enabled in settings.
-// Returns false by default if settings cannot be loaded or the key is missing.
-func IsSummarizeEnabled(ctx context.Context) bool {
- settings, err := Load(ctx)
- if err != nil {
- return false
- }
- return settings.IsSummarizeEnabled()
-}
-
-// IsSummarizeEnabled checks if auto-summarize is enabled in this settings instance.
-func (s *TraceSettings) IsSummarizeEnabled() bool {
- if s.StrategyOptions == nil {
- return false
- }
- summarizeOpts, ok := s.StrategyOptions["summarize"].(map[string]any)
- if !ok {
- return false
- }
- enabled, ok := summarizeOpts["enabled"].(bool)
- if !ok {
- return false
- }
- return enabled
-}
-
-// CheckpointRemoteConfig holds the structured checkpoint remote configuration.
-// Stored in strategy_options.checkpoint_remote as {"provider": "github", "repo": "org/repo"}.
-type CheckpointRemoteConfig struct {
- Provider string // e.g., "github"
- Repo string // e.g., "org/checkpoints-repo"
-}
-
-// Owner returns the owner portion of the repo field (before the slash).
-// Returns empty string if the repo field doesn't contain a slash.
-func (c *CheckpointRemoteConfig) Owner() string {
- parts := strings.SplitN(c.Repo, "/", 2)
- if len(parts) < 2 {
- return ""
- }
- return parts[0]
-}
-
-// GetCheckpointRemote returns the configured checkpoint remote.
-// Expects a structured object: {"provider": "github", "repo": "org/repo"}.
-// Returns nil if not configured, wrong type, or missing required fields.
-func (s *TraceSettings) GetCheckpointRemote() *CheckpointRemoteConfig {
- if s.StrategyOptions == nil {
- return nil
- }
- val, ok := s.StrategyOptions["checkpoint_remote"]
- if !ok {
- return nil
- }
- m, ok := val.(map[string]any)
- if !ok {
- return nil
- }
- provider, providerOK := m["provider"].(string)
- repo, repoOK := m["repo"].(string)
- if !providerOK || !repoOK || provider == "" || repo == "" {
- return nil
- }
- if !strings.Contains(repo, "/") {
- return nil
- }
- return &CheckpointRemoteConfig{Provider: provider, Repo: repo}
-}
-
-// IsCheckpointsV2Enabled checks if checkpoints v2 is enabled.
-// Returns true when either checkpoints_v2 is set or checkpoints_version is 2.
-func (s *TraceSettings) IsCheckpointsV2Enabled() bool {
- if s.CheckpointsVersion() == 2 {
- return true
- }
- if s.StrategyOptions == nil {
- return false
- }
- val, ok := s.StrategyOptions["checkpoints_v2"].(bool)
- return ok && val
-}
-
-// CheckpointsVersion returns the configured checkpoints format version from
-// strategy_options.checkpoints_version. Returns 1 when unset, invalid, or
-// unsupported. The currently supported versions are 1 and 2.
-func (s *TraceSettings) CheckpointsVersion() int {
- if s.StrategyOptions == nil {
- return 1
- }
- val, ok := s.StrategyOptions["checkpoints_version"]
- if !ok {
- return 1
- }
- version, ok := parseCheckpointsVersion(val)
- if ok {
- return version
- }
- return 1
-}
-
-func parseCheckpointsVersion(val any) (int, bool) {
- v, ok := val.(int)
- if ok && (v == 1 || v == 2) {
- return v, true
- }
- floatV, ok := val.(float64)
- if ok && (floatV == 1 || floatV == 2) {
- return int(floatV), true
- }
- stringV, ok := val.(string)
- if ok {
- parsed, err := strconv.Atoi(stringV)
- if err == nil && (parsed == 1 || parsed == 2) {
- return parsed, true
- }
- }
- return 1, false
-}
-
-// IsPushV2RefsEnabled checks if pushing v2 refs is enabled.
-// checkpoints_version: 2 forces v2 ref pushes on, regardless of push_v2_refs.
-func (s *TraceSettings) IsPushV2RefsEnabled() bool {
- if s.CheckpointsVersion() == 2 {
- return true
- }
- if !s.IsCheckpointsV2Enabled() {
- return false
- }
- if s.StrategyOptions == nil {
- return false
- }
- val, ok := s.StrategyOptions["push_v2_refs"].(bool)
- return ok && val
-}
-
-// GetFullTranscriptGenerationRetentionDays returns the retention window for
-// archived checkpoints v2 /full/* generations. Invalid, missing, or
-// non-positive values fall back to the documented default.
-func (s *TraceSettings) GetFullTranscriptGenerationRetentionDays() int {
- if s.StrategyOptions == nil {
- return defaultGenerationRetentionDays
- }
-
- val, ok := s.StrategyOptions["full_transcript_generation_retention_days"]
- if !ok {
- return defaultGenerationRetentionDays
- }
-
- switch days := val.(type) {
- case int:
- if days > 0 {
- return days
- }
- case float64:
- intDays := int(days)
- if intDays > 0 && days == float64(intDays) {
- return intDays
- }
- }
-
- return defaultGenerationRetentionDays
-}
-
-// IsFilteredFetchesEnabled checks if fetches should use --filter=blob:none.
-// When enabled, filtered fetches always use resolved URLs rather than remote
-// names to avoid persisting promisor settings onto named remotes.
-func (s *TraceSettings) IsFilteredFetchesEnabled() bool {
- if s.StrategyOptions == nil {
- return false
- }
- val, ok := s.StrategyOptions["filtered_fetches"].(bool)
- return ok && val
-}
-
-// IsPushSessionsDisabled checks if push_sessions is disabled in settings.
-// Returns true if push_sessions is explicitly set to false.
-func (s *TraceSettings) IsPushSessionsDisabled() bool {
- if s.StrategyOptions == nil {
- return false
- }
- val, exists := s.StrategyOptions["push_sessions"]
- if !exists {
- return false
- }
- if boolVal, ok := val.(bool); ok {
- return !boolVal // disabled = !push_sessions
- }
- return false
-}
-
-// IsExternalAgentsEnabled checks if external agent discovery is enabled in settings.
-// Returns false by default if settings cannot be loaded or the key is missing.
-func IsExternalAgentsEnabled(ctx context.Context) bool {
- s, err := Load(ctx)
- if err != nil {
- return false
- }
- return s.ExternalAgents
-}
-
-// IsSignCheckpointCommitsEnabled returns true if checkpoint commits should be signed.
-// Defaults to true when the setting is not explicitly set.
-func (s *TraceSettings) IsSignCheckpointCommitsEnabled() bool {
- return s.SignCheckpointCommits == nil || *s.SignCheckpointCommits
-}
-
-// IsSignCheckpointCommitsEnabled checks if checkpoint commit signing is enabled in settings.
-// Returns true by default if settings cannot be loaded or the key is missing.
-func IsSignCheckpointCommitsEnabled(ctx context.Context) bool {
- s, err := Load(ctx)
- if err != nil {
- return true
- }
- return s.IsSignCheckpointCommitsEnabled()
-}
-
-// Save saves the settings to .trace/settings.json.
-func Save(ctx context.Context, settings *TraceSettings) error {
- return saveToFile(ctx, settings, TraceSettingsFile)
-}
-
-// SaveLocal saves the settings to .trace/settings.local.json.
-func SaveLocal(ctx context.Context, settings *TraceSettings) error {
- return saveToFile(ctx, settings, TraceSettingsLocalFile)
-}
-
-// saveToFile saves settings to the specified file path.
-func saveToFile(ctx context.Context, settings *TraceSettings, filePath string) error {
- // Get absolute path for the file
- filePathAbs, err := paths.AbsPath(ctx, filePath)
- if err != nil {
- filePathAbs = filePath // Fallback to relative
- }
-
- // Ensure directory exists
- dir := filepath.Dir(filePathAbs)
- if err := os.MkdirAll(dir, 0o750); err != nil {
- return fmt.Errorf("creating settings directory: %w", err)
- }
-
- data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
- if err != nil {
- return fmt.Errorf("marshaling settings: %w", err)
- }
-
- //nolint:gosec // G306: settings file is config, not secrets; 0o644 is appropriate
- if err := os.WriteFile(filePathAbs, data, 0o644); err != nil {
- return fmt.Errorf("writing settings file: %w", err)
- }
- return nil
-}
-
-// --- Clone-local preferences and raw settings helpers (ported from upstream for review migration) ---
-
-// ClonePreferences holds per-clone (non-committed) preferences, primarily for
-// the review feature (e.g. which agent to use for review fixes, migration dismissal state).
-type ClonePreferences struct {
- Review map[string]ReviewConfig `json:"review,omitempty"`
- ReviewFixAgent string `json:"review_fix_agent,omitempty"`
-
- // ReviewMigrationDismissed records that the user declined the one-shot
- // migration of review keys from project settings to clone-local prefs.
- // Once true, `trace review` stops prompting on every invocation.
- ReviewMigrationDismissed bool `json:"review_migration_dismissed,omitempty"`
-}
-
-// LoadProjectRaw reads .trace/settings.json as a generic JSON object.
-// Used by review migration to move keys without loading the full typed struct.
-func LoadProjectRaw(ctx context.Context) (path string, raw map[string]json.RawMessage, exists bool, err error) {
- path, err = paths.AbsPath(ctx, TraceSettingsFile)
- if err != nil {
- path = TraceSettingsFile
- }
- data, readErr := os.ReadFile(path) //nolint:gosec
- if readErr != nil {
- if os.IsNotExist(readErr) {
- return path, map[string]json.RawMessage{}, false, nil
- }
- return path, nil, false, fmt.Errorf("reading project settings: %w", readErr)
- }
- raw = map[string]json.RawMessage{}
- if err := json.Unmarshal(data, &raw); err != nil {
- return path, nil, true, fmt.Errorf("parsing project settings: %w", err)
- }
- return path, raw, true, nil
-}
-
-// LoadLocalRaw reads .trace/settings.local.json as a generic JSON object.
-func LoadLocalRaw(ctx context.Context) (path string, raw map[string]json.RawMessage, exists bool, err error) {
- path, err = paths.AbsPath(ctx, TraceSettingsLocalFile)
- if err != nil {
- path = TraceSettingsLocalFile
- }
- data, readErr := os.ReadFile(path) //nolint:gosec
- if readErr != nil {
- if os.IsNotExist(readErr) {
- return path, map[string]json.RawMessage{}, false, nil
- }
- return path, nil, false, fmt.Errorf("reading local settings: %w", readErr)
- }
- raw = map[string]json.RawMessage{}
- if err := json.Unmarshal(data, &raw); err != nil {
- return path, nil, true, fmt.Errorf("parsing local settings: %w", err)
- }
- return path, raw, true, nil
-}
-
-// SaveProjectRaw writes a generic JSON object back to .trace/settings.json atomically.
-func SaveProjectRaw(path string, raw map[string]json.RawMessage) error {
- data, err := jsonutil.MarshalIndentWithNewline(raw, "", " ")
- if err != nil {
- return fmt.Errorf("marshal project settings: %w", err)
- }
- if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil {
- return fmt.Errorf("writing project settings: %w", err)
- }
- return nil
-}
-
-// ClonePreferencesPath returns the path to trace/preferences.json inside the git common dir.
-func ClonePreferencesPath(ctx context.Context) (string, error) {
- commonDir, err := session.GetGitCommonDir(ctx)
- if err != nil {
- return "", fmt.Errorf("get git common dir: %w", err)
- }
- return filepath.Join(commonDir, ClonePreferencesFile), nil
-}
-
-// LoadClonePreferences loads clone-local preferences from the git common dir.
-func LoadClonePreferences(ctx context.Context) (*ClonePreferences, error) {
- path, err := ClonePreferencesPath(ctx)
- if err != nil {
- return nil, err
- }
- return loadClonePreferencesFromFile(path)
-}
-
-// SaveClonePreferences saves clone-local preferences to the git common dir.
-func SaveClonePreferences(ctx context.Context, prefs *ClonePreferences) error {
- path, err := ClonePreferencesPath(ctx)
- if err != nil {
- return err
- }
- return saveClonePreferencesToFile(prefs, path)
-}
-
-func loadClonePreferencesFromFile(filePath string) (*ClonePreferences, error) {
- prefs := &ClonePreferences{}
- data, err := os.ReadFile(filePath) //nolint:gosec
- if err != nil {
- if os.IsNotExist(err) {
- return prefs, nil
- }
- return nil, fmt.Errorf("%w", err)
- }
- // Lenient decode (unknown fields are ignored) — same rationale as upstream.
- if err := json.Unmarshal(data, prefs); err != nil {
- return nil, fmt.Errorf("parsing preferences file: %w", err)
- }
- return prefs, nil
-}
-
-func saveClonePreferencesToFile(prefs *ClonePreferences, filePath string) error {
- if prefs == nil {
- prefs = &ClonePreferences{}
- }
- dir := filepath.Dir(filePath)
- if err := os.MkdirAll(dir, 0o750); err != nil {
- return fmt.Errorf("creating preferences directory: %w", err)
- }
- data, err := jsonutil.MarshalIndentWithNewline(prefs, "", " ")
- if err != nil {
- return fmt.Errorf("marshaling preferences: %w", err)
- }
- if err := jsonutil.WriteFileAtomic(filePath, data, 0o644); err != nil {
- return fmt.Errorf("writing preferences file: %w", err)
- }
- return nil
-}
diff --git a/cli/settings/settings_2.go b/cli/settings/settings_2.go
new file mode 100644
index 0000000..5b50dde
--- /dev/null
+++ b/cli/settings/settings_2.go
@@ -0,0 +1,542 @@
+package settings
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/jsonutil"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+)
+
+// mergePIISettings merges PII overrides into existing PIISettings.
+// Only fields present in the override JSON are applied; missing fields
+// are preserved from the base settings.
+func mergePIISettings(dst *PIISettings, data json.RawMessage) error {
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return fmt.Errorf("parsing pii: %w", err)
+ }
+ if v, ok := raw["enabled"]; ok {
+ if err := json.Unmarshal(v, &dst.Enabled); err != nil {
+ return fmt.Errorf("parsing pii.enabled: %w", err)
+ }
+ }
+ if v, ok := raw["email"]; ok {
+ var b bool
+ if err := json.Unmarshal(v, &b); err != nil {
+ return fmt.Errorf("parsing pii.email: %w", err)
+ }
+ dst.Email = &b
+ }
+ if v, ok := raw["phone"]; ok {
+ var b bool
+ if err := json.Unmarshal(v, &b); err != nil {
+ return fmt.Errorf("parsing pii.phone: %w", err)
+ }
+ dst.Phone = &b
+ }
+ if v, ok := raw["address"]; ok {
+ var b bool
+ if err := json.Unmarshal(v, &b); err != nil {
+ return fmt.Errorf("parsing pii.address: %w", err)
+ }
+ dst.Address = &b
+ }
+ if v, ok := raw["custom_patterns"]; ok {
+ var cp map[string]string
+ if err := json.Unmarshal(v, &cp); err != nil {
+ return fmt.Errorf("parsing pii.custom_patterns: %w", err)
+ }
+ if dst.CustomPatterns == nil {
+ dst.CustomPatterns = cp
+ } else {
+ for k, val := range cp {
+ dst.CustomPatterns[k] = val
+ }
+ }
+ }
+ return nil
+}
+
+// IsSetUp returns true if Trace has been set up in the current repository.
+// This checks if .trace/settings.json exists.
+// Use this to avoid creating files/directories in repos where Trace was never enabled.
+func IsSetUp(ctx context.Context) bool {
+ settingsFileAbs, err := paths.AbsPath(ctx, TraceSettingsFile)
+ if err != nil {
+ return false
+ }
+ _, err = os.Stat(settingsFileAbs)
+ return err == nil
+}
+
+// IsSetUpAny returns true if Trace has been set up in the current repository,
+// checking both .trace/settings.json and .trace/settings.local.json.
+// Use this to detect any prior setup, even if only local settings exist.
+func IsSetUpAny(ctx context.Context) bool {
+ if IsSetUp(ctx) {
+ return true
+ }
+ localFileAbs, err := paths.AbsPath(ctx, TraceSettingsLocalFile)
+ if err != nil {
+ return false
+ }
+ _, err = os.Lstat(localFileAbs)
+ return err == nil
+}
+
+// IsSetUpAndEnabled returns true if Trace is both set up and enabled.
+// This checks if .trace/settings.json exists AND has enabled: true.
+// Use this for hooks that should be no-ops when Trace is not active.
+func IsSetUpAndEnabled(ctx context.Context) bool {
+ if !IsSetUp(ctx) {
+ return false
+ }
+ s, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return s.Enabled
+}
+
+// IsCheckpointsV2Enabled checks if checkpoints v2 is enabled in settings.
+// Returns false by default if settings cannot be loaded or the key is missing.
+func IsCheckpointsV2Enabled(ctx context.Context) bool {
+ settings, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return settings.IsCheckpointsV2Enabled()
+}
+
+// CheckpointsVersion returns the configured checkpoints format version, or 1
+// if settings cannot be loaded or the value is unset/invalid.
+func CheckpointsVersion(ctx context.Context) int {
+ s, err := Load(ctx)
+ if err != nil {
+ return 1
+ }
+ version := s.CheckpointsVersion()
+ if s.StrategyOptions != nil {
+ if configured, ok := s.StrategyOptions["checkpoints_version"]; ok {
+ if _, supported := parseCheckpointsVersion(configured); !supported {
+ checkpointsVersionWarningOnce.Do(func() {
+ fmt.Fprintf(
+ os.Stderr,
+ "[trace] unsupported strategy_options.checkpoints_version %v detected in settings. Falling back to the default version (1).\n",
+ configured,
+ )
+ })
+ }
+ }
+ }
+ return version
+}
+
+// IsPushV2RefsEnabled checks if pushing v2 refs is enabled in settings.
+// Returns false by default if settings cannot be loaded or flags are missing.
+func IsPushV2RefsEnabled(ctx context.Context) bool {
+ s, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return s.IsPushV2RefsEnabled()
+}
+
+// IsFilteredFetchesEnabled checks if filtered fetches should be used.
+// When enabled, filtered fetches always resolve remote names to URLs first so
+// git does not persist promisor settings onto named remotes in local config.
+// Returns false by default.
+func IsFilteredFetchesEnabled(ctx context.Context) bool {
+ s, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return s.IsFilteredFetchesEnabled()
+}
+
+// IsSummarizeEnabled checks if auto-summarize is enabled in settings.
+// Returns false by default if settings cannot be loaded or the key is missing.
+func IsSummarizeEnabled(ctx context.Context) bool {
+ settings, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return settings.IsSummarizeEnabled()
+}
+
+// IsSummarizeEnabled checks if auto-summarize is enabled in this settings instance.
+func (s *TraceSettings) IsSummarizeEnabled() bool {
+ if s.StrategyOptions == nil {
+ return false
+ }
+ summarizeOpts, ok := s.StrategyOptions["summarize"].(map[string]any)
+ if !ok {
+ return false
+ }
+ enabled, ok := summarizeOpts["enabled"].(bool)
+ if !ok {
+ return false
+ }
+ return enabled
+}
+
+// CheckpointRemoteConfig holds the structured checkpoint remote configuration.
+// Stored in strategy_options.checkpoint_remote as {"provider": "github", "repo": "org/repo"}.
+type CheckpointRemoteConfig struct {
+ Provider string // e.g., "github"
+ Repo string // e.g., "org/checkpoints-repo"
+}
+
+// Owner returns the owner portion of the repo field (before the slash).
+// Returns empty string if the repo field doesn't contain a slash.
+func (c *CheckpointRemoteConfig) Owner() string {
+ parts := strings.SplitN(c.Repo, "/", 2)
+ if len(parts) < 2 {
+ return ""
+ }
+ return parts[0]
+}
+
+// GetCheckpointRemote returns the configured checkpoint remote.
+// Expects a structured object: {"provider": "github", "repo": "org/repo"}.
+// Returns nil if not configured, wrong type, or missing required fields.
+func (s *TraceSettings) GetCheckpointRemote() *CheckpointRemoteConfig {
+ if s.StrategyOptions == nil {
+ return nil
+ }
+ val, ok := s.StrategyOptions["checkpoint_remote"]
+ if !ok {
+ return nil
+ }
+ m, ok := val.(map[string]any)
+ if !ok {
+ return nil
+ }
+ provider, providerOK := m["provider"].(string)
+ repo, repoOK := m["repo"].(string)
+ if !providerOK || !repoOK || provider == "" || repo == "" {
+ return nil
+ }
+ if !strings.Contains(repo, "/") {
+ return nil
+ }
+ return &CheckpointRemoteConfig{Provider: provider, Repo: repo}
+}
+
+// IsCheckpointsV2Enabled checks if checkpoints v2 is enabled.
+// Returns true when either checkpoints_v2 is set or checkpoints_version is 2.
+func (s *TraceSettings) IsCheckpointsV2Enabled() bool {
+ if s.CheckpointsVersion() == 2 {
+ return true
+ }
+ if s.StrategyOptions == nil {
+ return false
+ }
+ val, ok := s.StrategyOptions["checkpoints_v2"].(bool)
+ return ok && val
+}
+
+// CheckpointsVersion returns the configured checkpoints format version from
+// strategy_options.checkpoints_version. Returns 1 when unset, invalid, or
+// unsupported. The currently supported versions are 1 and 2.
+func (s *TraceSettings) CheckpointsVersion() int {
+ if s.StrategyOptions == nil {
+ return 1
+ }
+ val, ok := s.StrategyOptions["checkpoints_version"]
+ if !ok {
+ return 1
+ }
+ version, ok := parseCheckpointsVersion(val)
+ if ok {
+ return version
+ }
+ return 1
+}
+
+func parseCheckpointsVersion(val any) (int, bool) {
+ v, ok := val.(int)
+ if ok && (v == 1 || v == 2) {
+ return v, true
+ }
+ floatV, ok := val.(float64)
+ if ok && (floatV == 1 || floatV == 2) {
+ return int(floatV), true
+ }
+ stringV, ok := val.(string)
+ if ok {
+ parsed, err := strconv.Atoi(stringV)
+ if err == nil && (parsed == 1 || parsed == 2) {
+ return parsed, true
+ }
+ }
+ return 1, false
+}
+
+// IsPushV2RefsEnabled checks if pushing v2 refs is enabled.
+// checkpoints_version: 2 forces v2 ref pushes on, regardless of push_v2_refs.
+func (s *TraceSettings) IsPushV2RefsEnabled() bool {
+ if s.CheckpointsVersion() == 2 {
+ return true
+ }
+ if !s.IsCheckpointsV2Enabled() {
+ return false
+ }
+ if s.StrategyOptions == nil {
+ return false
+ }
+ val, ok := s.StrategyOptions["push_v2_refs"].(bool)
+ return ok && val
+}
+
+// GetFullTranscriptGenerationRetentionDays returns the retention window for
+// archived checkpoints v2 /full/* generations. Invalid, missing, or
+// non-positive values fall back to the documented default.
+func (s *TraceSettings) GetFullTranscriptGenerationRetentionDays() int {
+ if s.StrategyOptions == nil {
+ return defaultGenerationRetentionDays
+ }
+
+ val, ok := s.StrategyOptions["full_transcript_generation_retention_days"]
+ if !ok {
+ return defaultGenerationRetentionDays
+ }
+
+ switch days := val.(type) {
+ case int:
+ if days > 0 {
+ return days
+ }
+ case float64:
+ intDays := int(days)
+ if intDays > 0 && days == float64(intDays) {
+ return intDays
+ }
+ }
+
+ return defaultGenerationRetentionDays
+}
+
+// IsFilteredFetchesEnabled checks if fetches should use --filter=blob:none.
+// When enabled, filtered fetches always use resolved URLs rather than remote
+// names to avoid persisting promisor settings onto named remotes.
+func (s *TraceSettings) IsFilteredFetchesEnabled() bool {
+ if s.StrategyOptions == nil {
+ return false
+ }
+ val, ok := s.StrategyOptions["filtered_fetches"].(bool)
+ return ok && val
+}
+
+// IsPushSessionsDisabled checks if push_sessions is disabled in settings.
+// Returns true if push_sessions is explicitly set to false.
+func (s *TraceSettings) IsPushSessionsDisabled() bool {
+ if s.StrategyOptions == nil {
+ return false
+ }
+ val, exists := s.StrategyOptions["push_sessions"]
+ if !exists {
+ return false
+ }
+ if boolVal, ok := val.(bool); ok {
+ return !boolVal // disabled = !push_sessions
+ }
+ return false
+}
+
+// IsExternalAgentsEnabled checks if external agent discovery is enabled in settings.
+// Returns false by default if settings cannot be loaded or the key is missing.
+func IsExternalAgentsEnabled(ctx context.Context) bool {
+ s, err := Load(ctx)
+ if err != nil {
+ return false
+ }
+ return s.ExternalAgents
+}
+
+// IsSignCheckpointCommitsEnabled returns true if checkpoint commits should be signed.
+// Defaults to true when the setting is not explicitly set.
+func (s *TraceSettings) IsSignCheckpointCommitsEnabled() bool {
+ return s.SignCheckpointCommits == nil || *s.SignCheckpointCommits
+}
+
+// IsSignCheckpointCommitsEnabled checks if checkpoint commit signing is enabled in settings.
+// Returns true by default if settings cannot be loaded or the key is missing.
+func IsSignCheckpointCommitsEnabled(ctx context.Context) bool {
+ s, err := Load(ctx)
+ if err != nil {
+ return true
+ }
+ return s.IsSignCheckpointCommitsEnabled()
+}
+
+// Save saves the settings to .trace/settings.json.
+func Save(ctx context.Context, settings *TraceSettings) error {
+ return saveToFile(ctx, settings, TraceSettingsFile)
+}
+
+// SaveLocal saves the settings to .trace/settings.local.json.
+func SaveLocal(ctx context.Context, settings *TraceSettings) error {
+ return saveToFile(ctx, settings, TraceSettingsLocalFile)
+}
+
+// saveToFile saves settings to the specified file path.
+func saveToFile(ctx context.Context, settings *TraceSettings, filePath string) error {
+ // Get absolute path for the file
+ filePathAbs, err := paths.AbsPath(ctx, filePath)
+ if err != nil {
+ filePathAbs = filePath // Fallback to relative
+ }
+
+ // Ensure directory exists
+ dir := filepath.Dir(filePathAbs)
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return fmt.Errorf("creating settings directory: %w", err)
+ }
+
+ data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling settings: %w", err)
+ }
+
+ //nolint:gosec // G306: settings file is config, not secrets; 0o644 is appropriate
+ if err := os.WriteFile(filePathAbs, data, 0o644); err != nil {
+ return fmt.Errorf("writing settings file: %w", err)
+ }
+ return nil
+}
+
+// --- Clone-local preferences and raw settings helpers (ported from upstream for review migration) ---
+
+// ClonePreferences holds per-clone (non-committed) preferences, primarily for
+// the review feature (e.g. which agent to use for review fixes, migration dismissal state).
+type ClonePreferences struct {
+ Review map[string]ReviewConfig `json:"review,omitempty"`
+ ReviewFixAgent string `json:"review_fix_agent,omitempty"`
+
+ // ReviewMigrationDismissed records that the user declined the one-shot
+ // migration of review keys from project settings to clone-local prefs.
+ // Once true, `trace review` stops prompting on every invocation.
+ ReviewMigrationDismissed bool `json:"review_migration_dismissed,omitempty"`
+}
+
+// LoadProjectRaw reads .trace/settings.json as a generic JSON object.
+// Used by review migration to move keys without loading the full typed struct.
+func LoadProjectRaw(ctx context.Context) (path string, raw map[string]json.RawMessage, exists bool, err error) {
+ path, err = paths.AbsPath(ctx, TraceSettingsFile)
+ if err != nil {
+ path = TraceSettingsFile
+ }
+ data, readErr := os.ReadFile(path) //nolint:gosec
+ if readErr != nil {
+ if os.IsNotExist(readErr) {
+ return path, map[string]json.RawMessage{}, false, nil
+ }
+ return path, nil, false, fmt.Errorf("reading project settings: %w", readErr)
+ }
+ raw = map[string]json.RawMessage{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return path, nil, true, fmt.Errorf("parsing project settings: %w", err)
+ }
+ return path, raw, true, nil
+}
+
+// LoadLocalRaw reads .trace/settings.local.json as a generic JSON object.
+func LoadLocalRaw(ctx context.Context) (path string, raw map[string]json.RawMessage, exists bool, err error) {
+ path, err = paths.AbsPath(ctx, TraceSettingsLocalFile)
+ if err != nil {
+ path = TraceSettingsLocalFile
+ }
+ data, readErr := os.ReadFile(path) //nolint:gosec
+ if readErr != nil {
+ if os.IsNotExist(readErr) {
+ return path, map[string]json.RawMessage{}, false, nil
+ }
+ return path, nil, false, fmt.Errorf("reading local settings: %w", readErr)
+ }
+ raw = map[string]json.RawMessage{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return path, nil, true, fmt.Errorf("parsing local settings: %w", err)
+ }
+ return path, raw, true, nil
+}
+
+// SaveProjectRaw writes a generic JSON object back to .trace/settings.json atomically.
+func SaveProjectRaw(path string, raw map[string]json.RawMessage) error {
+ data, err := jsonutil.MarshalIndentWithNewline(raw, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal project settings: %w", err)
+ }
+ if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil {
+ return fmt.Errorf("writing project settings: %w", err)
+ }
+ return nil
+}
+
+// ClonePreferencesPath returns the path to trace/preferences.json inside the git common dir.
+func ClonePreferencesPath(ctx context.Context) (string, error) {
+ commonDir, err := session.GetGitCommonDir(ctx)
+ if err != nil {
+ return "", fmt.Errorf("get git common dir: %w", err)
+ }
+ return filepath.Join(commonDir, ClonePreferencesFile), nil
+}
+
+// LoadClonePreferences loads clone-local preferences from the git common dir.
+func LoadClonePreferences(ctx context.Context) (*ClonePreferences, error) {
+ path, err := ClonePreferencesPath(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return loadClonePreferencesFromFile(path)
+}
+
+// SaveClonePreferences saves clone-local preferences to the git common dir.
+func SaveClonePreferences(ctx context.Context, prefs *ClonePreferences) error {
+ path, err := ClonePreferencesPath(ctx)
+ if err != nil {
+ return err
+ }
+ return saveClonePreferencesToFile(prefs, path)
+}
+
+func loadClonePreferencesFromFile(filePath string) (*ClonePreferences, error) {
+ prefs := &ClonePreferences{}
+ data, err := os.ReadFile(filePath) //nolint:gosec
+ if err != nil {
+ if os.IsNotExist(err) {
+ return prefs, nil
+ }
+ return nil, fmt.Errorf("%w", err)
+ }
+ // Lenient decode (unknown fields are ignored) — same rationale as upstream.
+ if err := json.Unmarshal(data, prefs); err != nil {
+ return nil, fmt.Errorf("parsing preferences file: %w", err)
+ }
+ return prefs, nil
+}
+
+func saveClonePreferencesToFile(prefs *ClonePreferences, filePath string) error {
+ if prefs == nil {
+ prefs = &ClonePreferences{}
+ }
+ dir := filepath.Dir(filePath)
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return fmt.Errorf("creating preferences directory: %w", err)
+ }
+ data, err := jsonutil.MarshalIndentWithNewline(prefs, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling preferences: %w", err)
+ }
+ if err := jsonutil.WriteFileAtomic(filePath, data, 0o644); err != nil {
+ return fmt.Errorf("writing preferences file: %w", err)
+ }
+ return nil
+}
diff --git a/cli/setup.go b/cli/setup.go
index ba32963..f83af09 100644
--- a/cli/setup.go
+++ b/cli/setup.go
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"os"
- "path/filepath"
"strings"
"github.com/GrayCodeAI/trace/cli/agent"
@@ -15,14 +14,11 @@ import (
"github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/strategy"
- "github.com/GrayCodeAI/trace/cli/vercelconfig"
"charm.land/huh/v2"
"github.com/spf13/cobra"
- "github.com/spf13/pflag"
)
// Config path display strings
@@ -699,1378 +695,3 @@ Examples:
return cmd
}
-
-func newEnableCmd() *cobra.Command {
- var opts EnableOptions
- var ignoreUntracked bool
- var agentName string
- var bootstrapOpts GitHubBootstrapOptions
-
- cmd := &cobra.Command{
- Use: "enable",
- Short: "Enable Trace in current repository",
- Long: `Enable Trace with session tracking for your AI agent workflows.
-
-If Trace is not yet configured, this runs the full configuration flow.
-If Trace is already configured but disabled, this re-enables it.
-
-If the current directory is not a git repository, Trace can initialize one
-for you and (optionally) create a matching GitHub repository via the gh CLI.`,
- RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
- ctx := cmd.Context()
- // Check if we're in a git repository first. If not, offer to
- // bootstrap one (git init + optional GitHub repo). If the user
- // declines, fall back to the legacy prerequisite error.
- //
- // The bootstrap runs in two phases: phase 1 (git init + identity
- // + gather GitHub choices) before agent setup, phase 2
- // (initial commit + gh repo create + push) after agent setup so
- // the initial commit captures the .trace/, .claude/, hooks, and
- // settings files that setup writes.
- var bootstrap *bootstrapState
- if _, err := paths.WorktreeRoot(ctx); err != nil {
- bootstrapOpts.Yes = opts.Yes
- state, bootstrapErr := runGitHubBootstrapInit(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), bootstrapOpts)
- if errors.Is(bootstrapErr, errBootstrapDeclined) {
- fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run 'trace enable' from within a git repository, or pass --init-repo to initialize one here.")
- return NewSilentError(errors.New("not a git repository"))
- }
- if errors.Is(bootstrapErr, errBootstrapInterrupted) {
- fmt.Fprintln(cmd.ErrOrStderr(), "Bootstrap cancelled. A local git repository has been initialized but setup didn't complete. Run `trace enable` again to continue.")
- return NewSilentError(errors.New("bootstrap interrupted"))
- }
- if bootstrapErr != nil {
- return bootstrapErr
- }
- bootstrap = state
- // Let the enable flow know that we'll be handling the final
- // "done" summary from the bootstrap finalize step.
- opts.SuppressDoneMessage = true
- // Re-check after bootstrap.
- if _, err := paths.WorktreeRoot(ctx); err != nil {
- return fmt.Errorf("bootstrap finished but no git repository detected: %w", err)
- }
- // Visual separator between bootstrap init and agent setup.
- printBootstrapSection(cmd.OutOrStdout(), "Enabling Trace")
- // On the way out (if setup succeeded), create the initial
- // commit and push to the GitHub repo. If setup returned an
- // error, skip the finalize — the user can fix the issue and
- // re-run; any partial state is just untracked files.
- defer func() {
- if runErr != nil || bootstrap == nil {
- return
- }
- if err := runGitHubBootstrapFinalize(ctx, cmd.OutOrStdout(), bootstrap); err != nil {
- runErr = err
- }
- }()
- }
-
- if err := validateSetupFlags(opts.UseLocalSettings, opts.UseProjectSettings); err != nil {
- return err
- }
-
- // Discover external agent plugins early so --agent can find them.
- // Use DiscoverAndRegisterAlways so that --agent works on fresh repos
- // where the external_agents setting hasn't been persisted yet.
- external.DiscoverAndRegisterAlways(ctx)
-
- // Non-interactive mode if --agent flag is provided
- if cmd.Flags().Changed(agentFlagName) && agentName == "" {
- printMissingAgentError(cmd.ErrOrStderr())
- return NewSilentError(errors.New("missing agent name"))
- }
-
- if agentName != "" {
- ag, err := agent.Get(types.AgentName(agentName))
- if err != nil {
- printWrongAgentError(cmd.ErrOrStderr(), agentName)
- return NewSilentError(errors.New("wrong agent name"))
- }
- // --agent is a targeted operation: set up this specific agent without
- // affecting other agents. Unlike the interactive path, it does not
- // uninstall hooks for other previously-enabled agents.
- return setupAgentHooksNonInteractive(ctx, cmd.OutOrStdout(), ag, opts)
- }
-
- // Any setup-mutating flags should behave like `configure` on repos that
- // are already set up. Bare `enable` remains the lightweight re-enable path.
- if settings.IsSetUpAny(ctx) {
- usedSetupFlow := enableUsesSetupFlow(cmd, agentName)
- if usedSetupFlow {
- if hasStrategyFlags(cmd) {
- if err := updateStrategyOptions(ctx, cmd.OutOrStdout(), opts); err != nil {
- return err
- }
- }
- if enableNeedsAgentManagement(cmd) {
- var selectFn func(available []string) ([]string, error)
- if opts.Yes {
- selectFn = selectAllAgents
- }
- if err := runManageAgents(ctx, cmd.OutOrStdout(), opts, selectFn); err != nil {
- return err
- }
- }
- }
-
- enabled, err := IsEnabled(ctx)
- if err == nil && enabled {
- w := cmd.OutOrStdout()
- if !usedSetupFlow {
- fmt.Fprintln(w, "Trace is already enabled.")
- }
- printEnabledStatus(ctx, w)
- return nil
- }
- return runEnable(ctx, cmd.OutOrStdout(), opts.UseProjectSettings)
- }
-
- // Fresh repo — run full setup flow
- return runSetupFlow(ctx, cmd.OutOrStdout(), opts)
- },
- }
-
- cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of trace binary for hooks")
- cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above
- cmd.Flags().BoolVar(&ignoreUntracked, "ignore-untracked", false, "Commit all new files without tracking pre-existing untracked files")
- cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above
- cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json")
- cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists")
- cmd.Flags().StringVar(&agentName, agentFlagName, "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"; external agents on $PATH are also available). Enables non-interactive mode.")
- cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Trace hooks first)")
- cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push")
- cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)")
- cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics")
- cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, flagAbsoluteGitHookPath, false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)")
- cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git, create private GitHub repo, commit; then enable all agents and accept telemetry)")
-
- // Bootstrap flags for non-git-repo folders.
- cmd.Flags().BoolVar(&bootstrapOpts.InitRepo, "init-repo", false, "If not a git repo, initialize one non-interactively")
- cmd.Flags().BoolVar(&bootstrapOpts.NoInitRepo, "no-init-repo", false, "If not a git repo, exit instead of prompting to initialize one")
- cmd.Flags().StringVar(&bootstrapOpts.RepoName, "repo-name", "", "GitHub repository name for the new repo (used when bootstrapping)")
- cmd.Flags().StringVar(&bootstrapOpts.RepoOwner, "repo-owner", "", "GitHub user or organization login for the new repo")
- cmd.Flags().StringVar(&bootstrapOpts.RepoVisibility, "repo-visibility", "", "GitHub repository visibility: public, private, or internal")
- cmd.Flags().BoolVar(&bootstrapOpts.NoGitHub, "no-github", false, "Initialize local git repo only; skip creating a GitHub remote")
- cmd.Flags().StringVar(&bootstrapOpts.InitialCommitMessage, "initial-commit-message", "", "Commit message for the initial commit when bootstrapping a new repo")
- cmd.Flags().BoolVar(&bootstrapOpts.SkipInitialCommit, "skip-initial-commit", false, "Don't create the initial commit when bootstrapping a new repo")
- cmd.MarkFlagsMutuallyExclusive("init-repo", "no-init-repo")
- cmd.MarkFlagsMutuallyExclusive("initial-commit-message", "skip-initial-commit")
-
- // Provide a helpful error when --agent is used without a value
- defaultFlagErr := cmd.FlagErrorFunc()
- cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
- var valErr *pflag.ValueRequiredError
- if errors.As(err, &valErr) && valErr.GetSpecifiedName() == agentFlagName {
- printMissingAgentError(c.ErrOrStderr())
- return NewSilentError(errors.New("missing agent name"))
- }
- return defaultFlagErr(c, err)
- })
-
- return cmd
-}
-
-func newDisableCmd() *cobra.Command {
- var useProjectSettings bool
- var uninstall bool
- var force bool
-
- cmd := &cobra.Command{
- Use: "disable",
- Short: "Disable Trace in current repository",
- Long: `Disable Trace integrations in the current repository.
-
-By default, this command will disable Trace. Hooks will exit silently and commands will
-show a disabled message.
-
-To completely remove Trace integrations from this repository, use --uninstall:
- - .trace/ directory (settings, logs, metadata)
- - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push)
- - Session state files (.git/trace-sessions/)
- - Shadow branches (trace/)
- - Agent hooks`,
- RunE: func(cmd *cobra.Command, _ []string) error {
- ctx := cmd.Context()
- if uninstall {
- return runUninstall(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), force)
- }
- return runDisable(ctx, cmd.OutOrStdout(), useProjectSettings)
- },
- }
-
- cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .trace/settings.json instead of .trace/settings.local.json")
- cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Completely remove Trace from this repository")
- cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt (use with --uninstall)")
-
- return cmd
-}
-
-// runEnableInteractive runs the interactive enable flow.
-// agents must be provided by the caller (via detectOrSelectAgent).
-func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions) error {
- // Uninstall hooks for agents that were previously active but are no longer selected
- if err := uninstallDeselectedAgentHooks(ctx, w, agents); err != nil {
- return fmt.Errorf("failed to clean up deselected agents: %w", err)
- }
-
- // Setup agent hooks for all selected agents
- for _, ag := range agents {
- if _, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil {
- return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err)
- }
- }
-
- // Setup .trace directory
- if _, err := setupTraceDirectory(ctx); err != nil {
- return fmt.Errorf("failed to setup .trace directory: %w", err)
- }
-
- // Load existing settings to preserve other options (like strategy_options.push)
- settings, err := LoadTraceSettings(ctx)
- if err != nil {
- // If we can't load, start with defaults
- settings = &TraceSettings{}
- }
- // Update the specific fields
- settings.Enabled = true
- if opts.LocalDev {
- settings.LocalDev = true
- }
- if opts.AbsoluteGitHookPath {
- settings.AbsoluteGitHookPath = true
- }
-
- // Auto-enable external_agents if any selected agent is external.
- for _, ag := range agents {
- if external.IsExternal(ag) {
- settings.ExternalAgents = true
- break
- }
- }
-
- opts.applyStrategyOptions(settings)
-
- // Determine which settings file to write to
- // First run always creates settings.json (no prompt)
- traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
- if err != nil {
- traceDirAbs = paths.TraceDir // Fallback to relative
- }
- shouldUseLocal, showNotification := determineSettingsTarget(traceDirAbs, opts.UseLocalSettings, opts.UseProjectSettings)
-
- if showNotification {
- fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.")
- fmt.Fprintln(w, " Use --project to update the project settings file.")
- }
-
- // Save settings to the appropriate file.
- targetFile := TraceSettingsFile
- if shouldUseLocal {
- targetFile = TraceSettingsLocalFile
- }
- saveSettings := func() error {
- return saveSettingsToTarget(ctx, settings, targetFile)
- }
- if err := saveSettings(); err != nil {
- return fmt.Errorf("failed to save settings: %w", err)
- }
-
- // Use settings values (merged from existing config + flags) for hook installation
- // This ensures re-running `trace enable` without flags preserves existing settings
- if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
- return fmt.Errorf("failed to install git hooks: %w", err)
- }
- strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
- fmt.Fprintln(w, " ✓ Installed hooks")
-
- configDisplay := configDisplayProject
- if shouldUseLocal {
- configDisplay = configDisplayLocal
- }
- fmt.Fprintln(w, " ✓ Configured project")
- fmt.Fprintf(w, " %s\n", configDisplay)
-
- var vercelPromptFn func() (bool, error)
- if opts.Yes {
- vercelPromptFn = func() (bool, error) { return true, nil }
- }
- if _, err := maybePromptVercelDeploymentDisable(ctx, w, targetFile, vercelPromptFn); err != nil {
- return err
- }
-
- // Ask about telemetry consent (only if not already asked).
- // --yes skips the interactive prompt but still respects --telemetry=false
- // and TRACE_TELEMETRY_OPTOUT — it only auto-answers the interactive question.
- if opts.Yes {
- if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- settings.Telemetry = &f
- } else if settings.Telemetry == nil {
- t := true
- settings.Telemetry = &t
- }
- } else if err := promptTelemetryConsent(settings, opts.Telemetry); err != nil {
- return fmt.Errorf("telemetry consent: %w", err)
- }
- // Save again to persist telemetry choice
- if err := saveSettings(); err != nil {
- return fmt.Errorf("failed to save settings: %w", err)
- }
-
- if err := strategy.EnsureSetup(ctx); err != nil {
- return fmt.Errorf("failed to setup strategy: %w", err)
- }
-
- if opts.SuppressDoneMessage {
- // Bootstrap finalize will print its own completion summary after
- // making the initial commit and pushing.
- return nil
- }
-
- fmt.Fprintln(w, "\nReady.")
-
- // Note about empty repos at the end, after setup is complete
- if repo, err := strategy.OpenRepository(ctx); err == nil && strategy.IsEmptyRepository(repo) {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "Note: Session checkpoints require at least one commit. To get started,")
- fmt.Fprintln(w, "commit the configuration files (e.g. .trace/, .claude/).")
- }
-
- return nil
-}
-
-// printEnabledStatus prints agents and a hint about `trace agent`.
-func printEnabledStatus(ctx context.Context, w io.Writer) {
- if displayNames := InstalledAgentDisplayNames(ctx); len(displayNames) > 0 {
- fmt.Fprintf(w, "Agents: %s\n", strings.Join(displayNames, ", "))
- }
- fmt.Fprintln(w, "\nTo add more agents, run `trace agent add `.")
-}
-
-// runEnable sets the enabled flag in settings.
-// Writes to the target file (local by default, project with --project),
-// and also updates the other file if it exists, so they can't get out of sync.
-func runEnable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
- s, err := LoadTraceSettings(ctx)
- if err != nil {
- return fmt.Errorf("failed to load settings: %w", err)
- }
-
- s.Enabled = true
-
- if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
- return err
- }
-
- fmt.Fprintln(w, "Trace is now enabled.")
- printEnabledStatus(ctx, w)
- return nil
-}
-
-func runDisable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
- s, err := LoadTraceSettings(ctx)
- if err != nil {
- return fmt.Errorf("failed to load settings: %w", err)
- }
-
- s.Enabled = false
-
- if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
- return err
- }
-
- fmt.Fprintln(w, "Trace is now disabled.")
- return nil
-}
-
-// saveEnabledState writes settings to the target file and also updates the
-// other settings file if it exists, preventing local/project from getting
-// out of sync on the enabled field.
-func saveEnabledState(ctx context.Context, s *TraceSettings, useProjectSettings bool) error {
- if useProjectSettings {
- if err := SaveTraceSettings(ctx, s); err != nil {
- return fmt.Errorf("failed to save settings: %w", err)
- }
- // Also update local if it exists, so it doesn't override
- if localExists(ctx) {
- if err := SaveTraceSettingsLocal(ctx, s); err != nil {
- return fmt.Errorf("failed to save local settings: %w", err)
- }
- }
- } else {
- if err := SaveTraceSettingsLocal(ctx, s); err != nil {
- return fmt.Errorf("failed to save local settings: %w", err)
- }
- }
- return nil
-}
-
-// localExists checks if settings.local.json exists.
-func localExists(ctx context.Context) bool {
- localFile := settings.TraceSettingsLocalFile
- if abs, err := paths.AbsPath(ctx, localFile); err == nil {
- localFile = abs
- }
- _, err := os.Stat(localFile)
- return err == nil
-}
-
-// runRemoveAgent removes hooks for a specific agent.
-func runRemoveAgent(ctx context.Context, w io.Writer, name string) error {
- ag, err := agent.Get(types.AgentName(name))
- if err != nil {
- printWrongAgentError(w, name)
- return NewSilentError(errors.New("wrong agent name"))
- }
-
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- return fmt.Errorf("agent %s does not support hooks", name)
- }
-
- if !hookAgent.AreHooksInstalled(ctx) {
- fmt.Fprintf(w, "%s hooks are not installed.\n", ag.Type())
- return nil
- }
-
- if err := hookAgent.UninstallHooks(ctx); err != nil {
- return fmt.Errorf("failed to remove %s hooks: %w", ag.Type(), err)
- }
-
- fmt.Fprintf(w, "Removed %s hooks.\n", ag.Type())
- return nil
-}
-
-// DisabledMessage is the message shown when Trace is disabled
-const DisabledMessage = "Trace is disabled. Run `trace enable` to re-enable."
-
-// checkDisabledGuard checks if Trace is disabled and prints a message if so.
-// Returns true if the caller should exit (i.e., Trace is disabled).
-// On error reading settings, defaults to enabled (returns false).
-func checkDisabledGuard(ctx context.Context, w io.Writer) bool {
- enabled, err := IsEnabled(ctx)
- if err != nil {
- // Default to enabled on error
- return false
- }
- if !enabled {
- fmt.Fprintln(w, DisabledMessage)
- return true
- }
- return false
-}
-
-// uninstallDeselectedAgentHooks removes hooks for agents that were previously
-// installed but are not in the selected list. This handles the case where a user
-// re-runs `trace enable` and deselects an agent.
-func uninstallDeselectedAgentHooks(ctx context.Context, w io.Writer, selectedAgents []agent.Agent) error {
- installedNames := GetAgentsWithHooksInstalled(ctx)
- if len(installedNames) == 0 {
- return nil
- }
-
- selectedSet := make(map[types.AgentName]struct{}, len(selectedAgents))
- for _, ag := range selectedAgents {
- selectedSet[ag.Name()] = struct{}{}
- }
-
- var errs []error
- for _, name := range installedNames {
- if _, selected := selectedSet[name]; selected {
- continue
- }
- ag, err := agent.Get(name)
- if err != nil {
- continue
- }
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- continue
- }
- if err := hookAgent.UninstallHooks(ctx); err != nil {
- errs = append(errs, fmt.Errorf("failed to uninstall %s hooks: %w", ag.Type(), err))
- } else {
- fmt.Fprintf(w, "Removed %s hooks\n", ag.Type())
- }
- }
- return errors.Join(errs...)
-}
-
-// setupAgentHooks sets up hooks for a given agent.
-// Returns the number of hooks installed (0 if already installed).
-func setupAgentHooks(ctx context.Context, w io.Writer, ag agent.Agent, localDev, forceHooks bool) (int, error) {
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- return 0, fmt.Errorf("agent %s does not support hooks", ag.Name())
- }
-
- count, err := hookAgent.InstallHooks(ctx, localDev, forceHooks)
- if err != nil {
- return 0, fmt.Errorf("failed to install %s hooks: %w", ag.Name(), err)
- }
-
- scaffoldResult, err := scaffoldSearchSubagent(ctx, ag)
- if err != nil {
- return 0, fmt.Errorf("failed to scaffold %s search subagent: %w", ag.Name(), err)
- }
- reportSearchSubagentScaffold(w, ag, scaffoldResult)
-
- return count, nil
-}
-
-// detectOrSelectAgent tries to auto-detect agents, or prompts the user to select.
-// Returns the detected/selected agents and any error.
-//
-// On first run (no hooks installed):
-// - Single detected built-in agent: used automatically
-// - Single detected external agent: interactive multi-select prompt
-// - Multiple/no detected agents: interactive multi-select prompt
-//
-// On re-run (hooks already installed):
-// - Always shows the interactive multi-select
-// - Pre-selects only agents that have hooks installed (respects prior deselection)
-//
-// selectFn overrides the interactive prompt for testing. When nil, the real form is used.
-// It receives available agent names and returns the selected names.
-func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(available []string) ([]string, error)) ([]agent.Agent, error) {
- // Check for agents with hooks already installed (re-run detection)
- installedAgentNames := GetAgentsWithHooksInstalled(ctx)
- hasInstalledHooks := len(installedAgentNames) > 0
-
- // Try auto-detection
- detected := agent.DetectAll(ctx)
-
- // First run: use existing auto-detect shortcuts
- if !hasInstalledHooks {
- switch {
- case len(detected) == 1:
- if isBuiltInAgent(detected[0]) {
- // When a selectFn is provided (e.g. --yes), skip the single-agent
- // shortcut so the caller's selection logic runs instead.
- if selectFn == nil {
- fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
- return detected, nil
- }
- }
-
- case len(detected) > 1:
- agentTypes := make([]string, 0, len(detected))
- for _, ag := range detected {
- agentTypes = append(agentTypes, string(ag.Type()))
- }
- fmt.Fprintf(w, "Detected multiple agents: %s\n", strings.Join(agentTypes, ", "))
- fmt.Fprintln(w)
- }
- }
-
- // When no selectFn is provided, check if we can prompt interactively.
- // A selectFn (e.g. from --yes) bypasses the interactive prompt entirely.
- if selectFn == nil && !interactive.CanPromptInteractively() {
- if hasInstalledHooks {
- // Re-run without TTY — keep currently installed agents
- agents := make([]agent.Agent, 0, len(installedAgentNames))
- for _, name := range installedAgentNames {
- ag, err := agent.Get(name)
- if err != nil {
- continue
- }
- agents = append(agents, ag)
- }
- return agents, nil
- }
- if len(detected) > 0 {
- return detected, nil
- }
- defaultAgent := agent.Default()
- if defaultAgent == nil {
- return nil, errors.New("no default agent available")
- }
- fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", defaultAgent.Type())
- return []agent.Agent{defaultAgent}, nil
- }
-
- // Build pre-selection set.
- // On re-run: only pre-select agents with hooks installed (respect prior deselection).
- // On first run: pre-select detected built-in agents only.
- preSelectedSet := make(map[types.AgentName]struct{})
- if hasInstalledHooks {
- for _, name := range installedAgentNames {
- preSelectedSet[name] = struct{}{}
- }
- } else {
- for _, ag := range detected {
- if isBuiltInAgent(ag) {
- preSelectedSet[ag.Name()] = struct{}{}
- }
- }
- }
-
- // Build options from registered agents
- agentNames := agent.List()
- options := make([]huh.Option[string], 0, len(agentNames))
- for _, name := range agentNames {
- ag, err := agent.Get(name)
- if err != nil {
- continue
- }
- // Only show agents that support hooks
- if _, ok := agent.AsHookSupport(ag); !ok {
- continue
- }
- // Skip test-only agents (e.g., Vogon canary)
- if to, ok := ag.(agent.TestOnly); ok && to.IsTestOnly() {
- continue
- }
- opt := huh.NewOption(string(ag.Type()), string(name))
- if _, isPreSelected := preSelectedSet[name]; isPreSelected {
- opt = opt.Selected(true)
- }
- options = append(options, opt)
- }
-
- if len(options) == 0 {
- return nil, errors.New("no agents with hook support available")
- }
-
- // Collect available agent names for the selector
- availableNames := make([]string, 0, len(options))
- for _, opt := range options {
- availableNames = append(availableNames, opt.Value)
- }
-
- var selectedAgentNames []string
- if selectFn != nil {
- var err error
- selectedAgentNames, err = selectFn(availableNames)
- if err != nil {
- return nil, err
- }
- if len(selectedAgentNames) == 0 {
- return nil, errors.New("no agents selected")
- }
- } else {
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewMultiSelect[string]().
- Title("Select the agents you want to use").
- Description("Use space to select, enter to confirm.").
- Options(options...).
- Validate(func(selected []string) error {
- if len(selected) == 0 {
- return errors.New("please select at least one agent")
- }
- return nil
- }).
- Value(&selectedAgentNames),
- ),
- )
- if err := form.Run(); err != nil {
- return nil, fmt.Errorf("agent selection cancelled: %w", err)
- }
- }
-
- selectedAgents := make([]agent.Agent, 0, len(selectedAgentNames))
- for _, name := range selectedAgentNames {
- selectedAgent, err := agent.Get(types.AgentName(name))
- if err != nil {
- return nil, fmt.Errorf("failed to get selected agent %s: %w", name, err)
- }
- selectedAgents = append(selectedAgents, selectedAgent)
- }
-
- agentTypes := make([]string, 0, len(selectedAgents))
- for _, ag := range selectedAgents {
- agentTypes = append(agentTypes, string(ag.Type()))
- }
- fmt.Fprintf(w, " Selected agents: %s\n", strings.Join(agentTypes, ", "))
- return selectedAgents, nil
-}
-
-func isBuiltInAgent(ag agent.Agent) bool {
- return !external.IsExternal(ag)
-}
-
-// printAgentError writes an error message followed by available agents and usage.
-func printAgentError(w io.Writer, message string) {
- agents := agent.List()
- fmt.Fprintf(w, "%s Available agents:\n", message)
- fmt.Fprintln(w)
- for _, a := range agents {
- suffix := ""
- if a == agent.DefaultAgentName {
- suffix = " (default)"
- }
- fmt.Fprintf(w, " %s%s\n", a, suffix)
- }
- fmt.Fprintln(w)
- fmt.Fprintln(w, "Usage: trace enable --agent ")
-}
-
-// printMissingAgentError writes a helpful error listing available agents.
-func printMissingAgentError(w io.Writer) {
- printAgentError(w, "Missing agent name.")
-}
-
-// printWrongAgentError writes a helpful error when an unknown agent name is provided.
-func printWrongAgentError(w io.Writer, name string) {
- printAgentError(w, fmt.Sprintf("Unknown agent %q.", name))
-}
-
-// setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively.
-// If strategyName is provided, it sets the strategy; otherwise uses default.
-func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Agent, opts EnableOptions) error {
- agentName := ag.Name()
- // Check if agent supports hooks
- if _, ok := agent.AsHookSupport(ag); !ok {
- return fmt.Errorf("agent %s does not support hooks", agentName)
- }
-
- fmt.Fprintf(w, " Agent: %s\n", ag.Type())
-
- // Install agent hooks (agent hooks don't depend on settings)
- installedHooks, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks)
- if err != nil {
- return fmt.Errorf("failed to setup %s hooks: %w", agentName, err)
- }
-
- // Setup .trace directory
- if _, err := setupTraceDirectory(ctx); err != nil {
- return fmt.Errorf("failed to setup .trace directory: %w", err)
- }
-
- // Load existing settings to preserve other options (like strategy_options.push)
- settings, err := LoadTraceSettings(ctx)
- if err != nil {
- // If we can't load, start with defaults
- settings = &TraceSettings{}
- }
- settings.Enabled = true
- if opts.LocalDev {
- settings.LocalDev = true
- }
- if opts.AbsoluteGitHookPath {
- settings.AbsoluteGitHookPath = true
- }
-
- // Auto-enable external_agents setting if the agent is external.
- if external.IsExternal(ag) {
- settings.ExternalAgents = true
- }
-
- opts.applyStrategyOptions(settings)
-
- // Handle telemetry for non-interactive mode
- // Note: if telemetry is nil (not configured), it defaults to disabled
- if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- settings.Telemetry = &f
- }
-
- targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
- if err := saveSettingsToTarget(ctx, settings, targetFile); err != nil {
- return fmt.Errorf("failed to save settings: %w", err)
- }
-
- // Use settings values (merged from existing config + flags) for hook installation
- // This ensures re-running `trace enable --agent X` without flags preserves existing settings
- if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
- return fmt.Errorf("failed to install git hooks: %w", err)
- }
- strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
-
- if installedHooks == 0 {
- msg := fmt.Sprintf("Hooks for %s already installed", ag.Description())
- if ag.IsPreview() {
- msg += " (Preview)"
- }
- fmt.Fprintf(w, " %s\n", msg)
- } else {
- msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description())
- if ag.IsPreview() {
- msg += " (Preview)"
- }
- fmt.Fprintf(w, " %s\n", msg)
- }
-
- fmt.Fprintln(w, " ✓ Configured project")
- fmt.Fprintf(w, " %s\n", configDisplay)
-
- if _, err := maybePromptVercelDeploymentDisable(ctx, w, targetFile, nil); err != nil {
- return err
- }
-
- if err := strategy.EnsureSetup(ctx); err != nil {
- return fmt.Errorf("failed to setup strategy: %w", err)
- }
-
- if opts.SuppressDoneMessage {
- // Bootstrap finalize will print its own completion summary.
- return nil
- }
-
- fmt.Fprintln(w, "\nReady.")
-
- if repo, err := strategy.OpenRepository(ctx); err == nil && strategy.IsEmptyRepository(repo) {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "Note: Session checkpoints require at least one commit. To get started,")
- fmt.Fprintln(w, "commit the configuration files (e.g. .trace/, .claude/).")
- }
-
- return nil
-}
-
-// validateSetupFlags checks that --local and --project flags are not both specified.
-func validateSetupFlags(useLocal, useProject bool) error {
- if useLocal && useProject {
- return errors.New("cannot specify both --project and --local")
- }
- return nil
-}
-
-// determineSettingsTarget decides whether to write to settings.local.json based on:
-// - Whether settings.json already exists
-// - The --local and --project flags
-// Returns (useLocal, showNotification).
-func determineSettingsTarget(traceDir string, useLocal, useProject bool) (bool, bool) {
- // Explicit --local flag always uses local settings
- if useLocal {
- return true, false
- }
-
- // Explicit --project flag always uses project settings
- if useProject {
- return false, false
- }
-
- // No flags specified - check if settings file exists
- settingsPath := filepath.Join(traceDir, paths.SettingsFileName)
- if _, err := os.Stat(settingsPath); err == nil {
- // Settings file exists - auto-redirect to local with notification
- return true, true
- }
-
- // Settings file doesn't exist - create it
- return false, false
-}
-
-// setupTraceDirectory creates the .trace directory and gitignore.
-// Returns true if the directory was created, false if it already existed.
-func setupTraceDirectory(ctx context.Context) (bool, error) { //nolint:unparam // already present in codebase
- // Get absolute path for the .trace directory
- traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
- if err != nil {
- traceDirAbs = paths.TraceDir // Fallback to relative
- }
-
- // Check if directory already exists
- created := false
- if _, err := os.Stat(traceDirAbs); os.IsNotExist(err) {
- created = true
- }
-
- // Create .trace directory
- //nolint:gosec // G301: Project directory needs standard permissions for git
- if err := os.MkdirAll(traceDirAbs, 0o755); err != nil {
- return false, fmt.Errorf("failed to create .trace directory: %w", err)
- }
-
- // Create/update .gitignore with all required entries
- if err := strategy.EnsureTraceGitignore(ctx); err != nil {
- return false, fmt.Errorf("failed to setup .gitignore: %w", err)
- }
-
- return created, nil
-}
-
-func newCurlBashPostInstallCmd() *cobra.Command {
- return &cobra.Command{
- Use: "curl-bash-post-install",
- Short: "Post-install tasks for curl|bash installer",
- Hidden: true,
- RunE: func(cmd *cobra.Command, _ []string) error {
- w := cmd.OutOrStdout()
- if err := promptShellCompletion(w); err != nil {
- fmt.Fprintf(w, "Note: Shell completion setup skipped: %v\n", err)
- }
- return nil
- },
- }
-}
-
-// shellCompletionComment is the comment preceding the completion line
-const shellCompletionComment = "# Trace CLI shell completion"
-
-// errUnsupportedShell is returned when the user's shell is not supported for completion.
-var errUnsupportedShell = errors.New("unsupported shell")
-
-// shellCompletionTarget returns the rc file path and completion lines for the
-// user's current shell.
-func shellCompletionTarget() (shellName, rcFile, completionLine string, err error) {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", "", "", fmt.Errorf("cannot determine home directory: %w", err)
- }
-
- shell := os.Getenv("SHELL")
- switch {
- case strings.Contains(shell, "zsh"):
- return "Zsh",
- filepath.Join(home, ".zshrc"),
- "autoload -Uz compinit && compinit && source <(trace completion zsh)",
- nil
- case strings.Contains(shell, "bash"):
- bashRC := filepath.Join(home, ".bashrc")
- if _, err := os.Stat(filepath.Join(home, ".bash_profile")); err == nil {
- bashRC = filepath.Join(home, ".bash_profile")
- }
- return "Bash",
- bashRC,
- "source <(trace completion bash)",
- nil
- case strings.Contains(shell, "fish"):
- return "Fish",
- filepath.Join(home, ".config", "fish", "config.fish"),
- "trace completion fish | source",
- nil
- default:
- return "", "", "", errUnsupportedShell
- }
-}
-
-// promptShellCompletion offers to add shell completion to the user's rc file.
-// Only prompts if completion is not already configured.
-func promptShellCompletion(w io.Writer) error {
- shellName, rcFile, completionLine, err := shellCompletionTarget()
- if err != nil {
- if errors.Is(err, errUnsupportedShell) {
- fmt.Fprintf(w, "Note: Shell completion not available for your shell. Supported: zsh, bash, fish.\n")
- return nil
- }
- return fmt.Errorf("shell completion: %w", err)
- }
-
- if isCompletionConfigured(rcFile) {
- fmt.Fprintf(w, "✓ Shell completion already configured in %s\n", rcFile)
- return nil
- }
-
- var selected string
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewSelect[string]().
- Title(fmt.Sprintf("Enable shell completion? (detected: %s)", shellName)).
- Options(
- huh.NewOption("Yes", "yes"),
- huh.NewOption("No", "no"),
- ).
- Value(&selected),
- ),
- )
-
- if err := form.Run(); err != nil {
- //nolint:nilerr // User cancelled - not a fatal error, just skip
- return nil
- }
-
- if selected != "yes" {
- return nil
- }
-
- if err := appendShellCompletion(rcFile, completionLine); err != nil {
- return fmt.Errorf("failed to update %s: %w", rcFile, err)
- }
-
- fmt.Fprintf(w, "✓ Shell completion added to %s\n", rcFile)
- fmt.Fprintln(w, " Restart your shell to activate")
-
- return nil
-}
-
-// isCompletionConfigured checks if shell completion is already in the rc file.
-func isCompletionConfigured(rcFile string) bool {
- //nolint:gosec // G304: rcFile is constructed from home dir + known filename, not user input
- content, err := os.ReadFile(rcFile)
- if err != nil {
- return false // File doesn't exist or can't read, treat as not configured
- }
- return strings.Contains(string(content), "trace completion")
-}
-
-// appendShellCompletion adds the completion line to the rc file.
-func appendShellCompletion(rcFile, completionLine string) error {
- if err := os.MkdirAll(filepath.Dir(rcFile), 0o700); err != nil {
- return fmt.Errorf("creating directory: %w", err)
- }
- //nolint:gosec // G302: Shell rc files need 0644 for user readability
- f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return fmt.Errorf("opening file: %w", err)
- }
- defer f.Close()
-
- _, err = f.WriteString("\n" + shellCompletionComment + "\n" + completionLine + "\n")
- if err != nil {
- return fmt.Errorf("writing completion: %w", err)
- }
- return nil
-}
-
-// promptTelemetryConsent asks the user if they want to enable telemetry.
-// It modifies settings.Telemetry based on the user's choice or flags.
-// The caller is responsible for saving settings.
-func promptTelemetryConsent(settings *TraceSettings, telemetryFlag bool) error {
- // Handle --telemetry=false flag first (always overrides existing setting)
- if !telemetryFlag {
- f := false
- settings.Telemetry = &f
- return nil
- }
-
- // Skip if already asked
- if settings.Telemetry != nil {
- return nil
- }
-
- // Skip if env var disables telemetry (record as disabled)
- if os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- settings.Telemetry = &f
- return nil
- }
-
- consent := true // Default to Yes
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title("Help improve Trace CLI?").
- Description("Share anonymous usage data. No code or personal info collected.").
- Affirmative("Yes").
- Negative("No").
- Value(&consent),
- ),
- )
-
- if err := form.Run(); err != nil {
- return fmt.Errorf("telemetry prompt: %w", err)
- }
-
- settings.Telemetry = &consent
- return nil
-}
-
-func maybePromptVercelDeploymentDisable(ctx context.Context, w io.Writer, targetFile string, promptFn func() (bool, error)) (bool, error) {
- repoRoot, rootErr := paths.WorktreeRoot(ctx)
- if rootErr == nil {
- vercelJSONPath := filepath.Join(repoRoot, "vercel.json")
- hasVercelJSON := false
- if _, err := os.Stat(vercelJSONPath); err == nil {
- hasVercelJSON = true
- } else if !os.IsNotExist(err) {
- fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check vercel.json: %v\n", err)
- return false, nil
- }
-
- hasVercelProject := hasVercelJSON
- if !hasVercelProject {
- for _, path := range []string{
- filepath.Join(repoRoot, ".vercel"),
- filepath.Join(repoRoot, "vercel.ts"),
- } {
- if _, err := os.Stat(path); err == nil {
- hasVercelProject = true
- break
- } else if !os.IsNotExist(err) {
- fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check %s: %v\n", path, err)
- return false, nil
- }
- }
- }
-
- if !hasVercelProject {
- return false, nil
- }
-
- configDisplay := configDisplayProject
- if targetFile == settings.TraceSettingsLocalFile {
- configDisplay = configDisplayLocal
- }
-
- targetSettingsPath := filepath.Join(repoRoot, targetFile)
- targetSettings, err := settings.LoadFromFile(targetSettingsPath)
- if err != nil {
- return false, fmt.Errorf("load settings: %w", err)
- }
- if targetSettings.Vercel {
- return false, nil
- }
-
- if config, alreadyDisabled, loadErr := vercelconfig.Load(vercelJSONPath); loadErr == nil &&
- config != nil && alreadyDisabled {
- targetSettings.Vercel = true
- if err := saveSettingsToTarget(ctx, targetSettings, targetFile); err != nil {
- return false, fmt.Errorf("save settings: %w", err)
- }
- fmt.Fprintf(w, "✓ Updated %s to manage Vercel deployment blocking on `%s`\n", configDisplay, vercelconfig.BranchPattern)
- return true, nil
- }
-
- if promptFn == nil {
- if !interactive.CanPromptInteractively() {
- fmt.Fprintf(w, "Note: Vercel detected. Run `trace configure` interactively to disable deployments for `%s` branches.\n", vercelconfig.BranchPattern)
- return false, nil
- }
- promptFn = promptVercelDeploymentDisable
- }
-
- disableDeployments, err := promptFn()
- if err != nil {
- return false, fmt.Errorf("vercel prompt: %w", err)
- }
- if !disableDeployments {
- return false, nil
- }
-
- targetSettings.Vercel = true
- if err := saveSettingsToTarget(ctx, targetSettings, targetFile); err != nil {
- return false, fmt.Errorf("save settings: %w", err)
- }
-
- fmt.Fprintf(w, "✓ Updated %s to block Vercel deploys of Trace metadata branch\n", configDisplay)
- return true, nil
- }
-
- return false, nil
-}
-
-func promptVercelDeploymentDisable() (bool, error) {
- disableDeployments := true
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title("Disable Vercel deployments for Trace metadata branch?").
- Description("This automatically creates a vercel.json in the Trace metadata branch.").
- Affirmative("Yes").
- Negative("No").
- Value(&disableDeployments),
- ),
- )
-
- if err := form.Run(); err != nil {
- return false, fmt.Errorf("run vercel deployment disable form: %w", err)
- }
-
- return disableDeployments, nil
-}
-
-// runUninstall completely removes Trace from the repository.
-func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
- // Check if we're in a git repository
- if _, err := paths.WorktreeRoot(ctx); err != nil {
- fmt.Fprintln(errW, "Not a git repository. Nothing to uninstall.")
- return NewSilentError(errors.New("not a git repository"))
- }
-
- // Gather counts for display
- sessionStateCount := countSessionStates(ctx)
- shadowBranchCount := countShadowBranches(ctx)
- gitHooksInstalled := strategy.IsGitHookInstalled(ctx)
- agentsWithInstalledHooks := GetAgentsWithHooksInstalled(ctx)
- traceDirExists := checkTraceDirExists(ctx)
-
- // Check if there's anything to uninstall
- if !traceDirExists && !gitHooksInstalled && sessionStateCount == 0 &&
- shadowBranchCount == 0 && len(agentsWithInstalledHooks) == 0 {
- fmt.Fprintln(w, "Trace is not installed in this repository.")
- return nil
- }
-
- // Show confirmation prompt unless --force
- if !force {
- fmt.Fprintln(w, "\nThis will completely remove Trace from this repository:")
- if traceDirExists {
- fmt.Fprintln(w, " - .trace/ directory")
- }
- if gitHooksInstalled {
- fmt.Fprintln(w, " - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push)")
- }
- if sessionStateCount > 0 {
- fmt.Fprintf(w, " - Session state files (%d)\n", sessionStateCount)
- }
- if shadowBranchCount > 0 {
- fmt.Fprintf(w, " - Shadow branches (%d)\n", shadowBranchCount)
- }
- if len(agentsWithInstalledHooks) > 0 {
- displayNames := make([]string, 0, len(agentsWithInstalledHooks))
- for _, name := range agentsWithInstalledHooks {
- if ag, err := agent.Get(name); err == nil {
- displayNames = append(displayNames, string(ag.Type()))
- }
- }
- fmt.Fprintf(w, " - Agent hooks (%s)\n", strings.Join(displayNames, ", "))
- }
- fmt.Fprintln(w)
-
- var confirmed bool
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title("Are you sure you want to uninstall Trace?").
- Affirmative("Yes, uninstall").
- Negative("Cancel").
- Value(&confirmed),
- ),
- )
-
- if err := form.Run(); err != nil {
- return fmt.Errorf("confirmation cancelled: %w", err)
- }
-
- if !confirmed {
- fmt.Fprintln(w, "Uninstall cancelled.")
- return nil
- }
- }
-
- fmt.Fprintln(w, "\nUninstalling Trace CLI...")
-
- // 1. Remove agent hooks (lowest risk)
- if err := removeAgentHooks(ctx, w); err != nil {
- fmt.Fprintf(errW, "Warning: failed to remove agent hooks: %v\n", err)
- }
-
- // 2. Remove git hooks
- removed, err := strategy.RemoveGitHook(ctx)
- if err != nil {
- fmt.Fprintf(errW, "Warning: failed to remove git hooks: %v\n", err)
- } else if removed > 0 {
- fmt.Fprintf(w, " Removed git hooks (%d)\n", removed)
- }
-
- // 3. Remove session state files
- statesRemoved, err := removeAllSessionStates(ctx)
- if err != nil {
- fmt.Fprintf(errW, "Warning: failed to remove session states: %v\n", err)
- } else if statesRemoved > 0 {
- fmt.Fprintf(w, " Removed session states (%d)\n", statesRemoved)
- }
-
- // 4. Remove .trace/ directory
- if err := removeTraceDirectory(ctx); err != nil {
- fmt.Fprintf(errW, "Warning: failed to remove .trace directory: %v\n", err)
- } else if traceDirExists {
- fmt.Fprintln(w, " Removed .trace directory")
- }
-
- // 5. Remove shadow branches
- branchesRemoved, err := removeAllShadowBranches(ctx)
- if err != nil {
- fmt.Fprintf(errW, "Warning: failed to remove shadow branches: %v\n", err)
- } else if branchesRemoved > 0 {
- fmt.Fprintf(w, " Removed %d shadow branches\n", branchesRemoved)
- }
-
- fmt.Fprintln(w, "\nTrace CLI uninstalled successfully.")
- return nil
-}
-
-// countSessionStates returns the number of active session state files.
-func countSessionStates(ctx context.Context) int {
- store, err := session.NewStateStore(ctx)
- if err != nil {
- return 0
- }
- states, err := store.List(ctx)
- if err != nil {
- return 0
- }
- return len(states)
-}
-
-// countShadowBranches returns the number of shadow branches.
-func countShadowBranches(ctx context.Context) int {
- branches, err := strategy.ListShadowBranches(ctx)
- if err != nil {
- return 0
- }
- return len(branches)
-}
-
-// checkTraceDirExists checks if the .trace directory exists.
-func checkTraceDirExists(ctx context.Context) bool {
- traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
- if err != nil {
- traceDirAbs = paths.TraceDir
- }
- _, err = os.Stat(traceDirAbs)
- return err == nil
-}
-
-// removeAgentHooks removes hooks from all agents that support hooks.
-func removeAgentHooks(ctx context.Context, w io.Writer) error {
- var errs []error
- for _, name := range agent.List() {
- ag, err := agent.Get(name)
- if err != nil {
- continue
- }
- hs, ok := agent.AsHookSupport(ag)
- if !ok {
- continue
- }
- wasInstalled := hs.AreHooksInstalled(ctx)
- if err := hs.UninstallHooks(ctx); err != nil {
- errs = append(errs, err)
- } else if wasInstalled {
- fmt.Fprintf(w, " Removed %s hooks\n", ag.Type())
- }
- }
- return errors.Join(errs...)
-}
-
-// removeAllSessionStates removes all session state files and the directory.
-func removeAllSessionStates(ctx context.Context) (int, error) {
- store, err := session.NewStateStore(ctx)
- if err != nil {
- return 0, fmt.Errorf("failed to create state store: %w", err)
- }
-
- // Count states before removing
- states, err := store.List(ctx)
- if err != nil {
- return 0, fmt.Errorf("failed to list session states: %w", err)
- }
- count := len(states)
-
- // Remove the trace directory
- if err := store.RemoveAll(); err != nil {
- return 0, fmt.Errorf("failed to remove session states: %w", err)
- }
-
- return count, nil
-}
-
-// removeTraceDirectory removes the .trace directory.
-func removeTraceDirectory(ctx context.Context) error {
- traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
- if err != nil {
- traceDirAbs = paths.TraceDir
- }
- if err := os.RemoveAll(traceDirAbs); err != nil {
- return fmt.Errorf("failed to remove .trace directory: %w", err)
- }
- return nil
-}
-
-// removeAllShadowBranches removes all shadow branches.
-func removeAllShadowBranches(ctx context.Context) (int, error) {
- branches, err := strategy.ListShadowBranches(ctx)
- if err != nil {
- return 0, fmt.Errorf("failed to list shadow branches: %w", err)
- }
- if len(branches) == 0 {
- return 0, nil
- }
- deleted, _, err := strategy.DeleteShadowBranches(ctx, branches)
- return len(deleted), err
-}
diff --git a/cli/setup_2.go b/cli/setup_2.go
new file mode 100644
index 0000000..36ec293
--- /dev/null
+++ b/cli/setup_2.go
@@ -0,0 +1,739 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/external"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+
+ "charm.land/huh/v2"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+func newEnableCmd() *cobra.Command {
+ var opts EnableOptions
+ var ignoreUntracked bool
+ var agentName string
+ var bootstrapOpts GitHubBootstrapOptions
+
+ cmd := &cobra.Command{
+ Use: "enable",
+ Short: "Enable Trace in current repository",
+ Long: `Enable Trace with session tracking for your AI agent workflows.
+
+If Trace is not yet configured, this runs the full configuration flow.
+If Trace is already configured but disabled, this re-enables it.
+
+If the current directory is not a git repository, Trace can initialize one
+for you and (optionally) create a matching GitHub repository via the gh CLI.`,
+ RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
+ ctx := cmd.Context()
+ // Check if we're in a git repository first. If not, offer to
+ // bootstrap one (git init + optional GitHub repo). If the user
+ // declines, fall back to the legacy prerequisite error.
+ //
+ // The bootstrap runs in two phases: phase 1 (git init + identity
+ // + gather GitHub choices) before agent setup, phase 2
+ // (initial commit + gh repo create + push) after agent setup so
+ // the initial commit captures the .trace/, .claude/, hooks, and
+ // settings files that setup writes.
+ var bootstrap *bootstrapState
+ if _, err := paths.WorktreeRoot(ctx); err != nil {
+ bootstrapOpts.Yes = opts.Yes
+ state, bootstrapErr := runGitHubBootstrapInit(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), bootstrapOpts)
+ if errors.Is(bootstrapErr, errBootstrapDeclined) {
+ fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run 'trace enable' from within a git repository, or pass --init-repo to initialize one here.")
+ return NewSilentError(errors.New("not a git repository"))
+ }
+ if errors.Is(bootstrapErr, errBootstrapInterrupted) {
+ fmt.Fprintln(cmd.ErrOrStderr(), "Bootstrap cancelled. A local git repository has been initialized but setup didn't complete. Run `trace enable` again to continue.")
+ return NewSilentError(errors.New("bootstrap interrupted"))
+ }
+ if bootstrapErr != nil {
+ return bootstrapErr
+ }
+ bootstrap = state
+ // Let the enable flow know that we'll be handling the final
+ // "done" summary from the bootstrap finalize step.
+ opts.SuppressDoneMessage = true
+ // Re-check after bootstrap.
+ if _, err := paths.WorktreeRoot(ctx); err != nil {
+ return fmt.Errorf("bootstrap finished but no git repository detected: %w", err)
+ }
+ // Visual separator between bootstrap init and agent setup.
+ printBootstrapSection(cmd.OutOrStdout(), "Enabling Trace")
+ // On the way out (if setup succeeded), create the initial
+ // commit and push to the GitHub repo. If setup returned an
+ // error, skip the finalize — the user can fix the issue and
+ // re-run; any partial state is just untracked files.
+ defer func() {
+ if runErr != nil || bootstrap == nil {
+ return
+ }
+ if err := runGitHubBootstrapFinalize(ctx, cmd.OutOrStdout(), bootstrap); err != nil {
+ runErr = err
+ }
+ }()
+ }
+
+ if err := validateSetupFlags(opts.UseLocalSettings, opts.UseProjectSettings); err != nil {
+ return err
+ }
+
+ // Discover external agent plugins early so --agent can find them.
+ // Use DiscoverAndRegisterAlways so that --agent works on fresh repos
+ // where the external_agents setting hasn't been persisted yet.
+ external.DiscoverAndRegisterAlways(ctx)
+
+ // Non-interactive mode if --agent flag is provided
+ if cmd.Flags().Changed(agentFlagName) && agentName == "" {
+ printMissingAgentError(cmd.ErrOrStderr())
+ return NewSilentError(errors.New("missing agent name"))
+ }
+
+ if agentName != "" {
+ ag, err := agent.Get(types.AgentName(agentName))
+ if err != nil {
+ printWrongAgentError(cmd.ErrOrStderr(), agentName)
+ return NewSilentError(errors.New("wrong agent name"))
+ }
+ // --agent is a targeted operation: set up this specific agent without
+ // affecting other agents. Unlike the interactive path, it does not
+ // uninstall hooks for other previously-enabled agents.
+ return setupAgentHooksNonInteractive(ctx, cmd.OutOrStdout(), ag, opts)
+ }
+
+ // Any setup-mutating flags should behave like `configure` on repos that
+ // are already set up. Bare `enable` remains the lightweight re-enable path.
+ if settings.IsSetUpAny(ctx) {
+ usedSetupFlow := enableUsesSetupFlow(cmd, agentName)
+ if usedSetupFlow {
+ if hasStrategyFlags(cmd) {
+ if err := updateStrategyOptions(ctx, cmd.OutOrStdout(), opts); err != nil {
+ return err
+ }
+ }
+ if enableNeedsAgentManagement(cmd) {
+ var selectFn func(available []string) ([]string, error)
+ if opts.Yes {
+ selectFn = selectAllAgents
+ }
+ if err := runManageAgents(ctx, cmd.OutOrStdout(), opts, selectFn); err != nil {
+ return err
+ }
+ }
+ }
+
+ enabled, err := IsEnabled(ctx)
+ if err == nil && enabled {
+ w := cmd.OutOrStdout()
+ if !usedSetupFlow {
+ fmt.Fprintln(w, "Trace is already enabled.")
+ }
+ printEnabledStatus(ctx, w)
+ return nil
+ }
+ return runEnable(ctx, cmd.OutOrStdout(), opts.UseProjectSettings)
+ }
+
+ // Fresh repo — run full setup flow
+ return runSetupFlow(ctx, cmd.OutOrStdout(), opts)
+ },
+ }
+
+ cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of trace binary for hooks")
+ cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above
+ cmd.Flags().BoolVar(&ignoreUntracked, "ignore-untracked", false, "Commit all new files without tracking pre-existing untracked files")
+ cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above
+ cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json")
+ cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists")
+ cmd.Flags().StringVar(&agentName, agentFlagName, "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"; external agents on $PATH are also available). Enables non-interactive mode.")
+ cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Trace hooks first)")
+ cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push")
+ cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)")
+ cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics")
+ cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, flagAbsoluteGitHookPath, false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)")
+ cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git, create private GitHub repo, commit; then enable all agents and accept telemetry)")
+
+ // Bootstrap flags for non-git-repo folders.
+ cmd.Flags().BoolVar(&bootstrapOpts.InitRepo, "init-repo", false, "If not a git repo, initialize one non-interactively")
+ cmd.Flags().BoolVar(&bootstrapOpts.NoInitRepo, "no-init-repo", false, "If not a git repo, exit instead of prompting to initialize one")
+ cmd.Flags().StringVar(&bootstrapOpts.RepoName, "repo-name", "", "GitHub repository name for the new repo (used when bootstrapping)")
+ cmd.Flags().StringVar(&bootstrapOpts.RepoOwner, "repo-owner", "", "GitHub user or organization login for the new repo")
+ cmd.Flags().StringVar(&bootstrapOpts.RepoVisibility, "repo-visibility", "", "GitHub repository visibility: public, private, or internal")
+ cmd.Flags().BoolVar(&bootstrapOpts.NoGitHub, "no-github", false, "Initialize local git repo only; skip creating a GitHub remote")
+ cmd.Flags().StringVar(&bootstrapOpts.InitialCommitMessage, "initial-commit-message", "", "Commit message for the initial commit when bootstrapping a new repo")
+ cmd.Flags().BoolVar(&bootstrapOpts.SkipInitialCommit, "skip-initial-commit", false, "Don't create the initial commit when bootstrapping a new repo")
+ cmd.MarkFlagsMutuallyExclusive("init-repo", "no-init-repo")
+ cmd.MarkFlagsMutuallyExclusive("initial-commit-message", "skip-initial-commit")
+
+ // Provide a helpful error when --agent is used without a value
+ defaultFlagErr := cmd.FlagErrorFunc()
+ cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
+ var valErr *pflag.ValueRequiredError
+ if errors.As(err, &valErr) && valErr.GetSpecifiedName() == agentFlagName {
+ printMissingAgentError(c.ErrOrStderr())
+ return NewSilentError(errors.New("missing agent name"))
+ }
+ return defaultFlagErr(c, err)
+ })
+
+ return cmd
+}
+
+func newDisableCmd() *cobra.Command {
+ var useProjectSettings bool
+ var uninstall bool
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "disable",
+ Short: "Disable Trace in current repository",
+ Long: `Disable Trace integrations in the current repository.
+
+By default, this command will disable Trace. Hooks will exit silently and commands will
+show a disabled message.
+
+To completely remove Trace integrations from this repository, use --uninstall:
+ - .trace/ directory (settings, logs, metadata)
+ - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push)
+ - Session state files (.git/trace-sessions/)
+ - Shadow branches (trace/)
+ - Agent hooks`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ if uninstall {
+ return runUninstall(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), force)
+ }
+ return runDisable(ctx, cmd.OutOrStdout(), useProjectSettings)
+ },
+ }
+
+ cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .trace/settings.json instead of .trace/settings.local.json")
+ cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Completely remove Trace from this repository")
+ cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt (use with --uninstall)")
+
+ return cmd
+}
+
+// runEnableInteractive runs the interactive enable flow.
+// agents must be provided by the caller (via detectOrSelectAgent).
+func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions) error {
+ // Uninstall hooks for agents that were previously active but are no longer selected
+ if err := uninstallDeselectedAgentHooks(ctx, w, agents); err != nil {
+ return fmt.Errorf("failed to clean up deselected agents: %w", err)
+ }
+
+ // Setup agent hooks for all selected agents
+ for _, ag := range agents {
+ if _, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil {
+ return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err)
+ }
+ }
+
+ // Setup .trace directory
+ if _, err := setupTraceDirectory(ctx); err != nil {
+ return fmt.Errorf("failed to setup .trace directory: %w", err)
+ }
+
+ // Load existing settings to preserve other options (like strategy_options.push)
+ settings, err := LoadTraceSettings(ctx)
+ if err != nil {
+ // If we can't load, start with defaults
+ settings = &TraceSettings{}
+ }
+ // Update the specific fields
+ settings.Enabled = true
+ if opts.LocalDev {
+ settings.LocalDev = true
+ }
+ if opts.AbsoluteGitHookPath {
+ settings.AbsoluteGitHookPath = true
+ }
+
+ // Auto-enable external_agents if any selected agent is external.
+ for _, ag := range agents {
+ if external.IsExternal(ag) {
+ settings.ExternalAgents = true
+ break
+ }
+ }
+
+ opts.applyStrategyOptions(settings)
+
+ // Determine which settings file to write to
+ // First run always creates settings.json (no prompt)
+ traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
+ if err != nil {
+ traceDirAbs = paths.TraceDir // Fallback to relative
+ }
+ shouldUseLocal, showNotification := determineSettingsTarget(traceDirAbs, opts.UseLocalSettings, opts.UseProjectSettings)
+
+ if showNotification {
+ fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.")
+ fmt.Fprintln(w, " Use --project to update the project settings file.")
+ }
+
+ // Save settings to the appropriate file.
+ targetFile := TraceSettingsFile
+ if shouldUseLocal {
+ targetFile = TraceSettingsLocalFile
+ }
+ saveSettings := func() error {
+ return saveSettingsToTarget(ctx, settings, targetFile)
+ }
+ if err := saveSettings(); err != nil {
+ return fmt.Errorf("failed to save settings: %w", err)
+ }
+
+ // Use settings values (merged from existing config + flags) for hook installation
+ // This ensures re-running `trace enable` without flags preserves existing settings
+ if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
+ return fmt.Errorf("failed to install git hooks: %w", err)
+ }
+ strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
+ fmt.Fprintln(w, " ✓ Installed hooks")
+
+ configDisplay := configDisplayProject
+ if shouldUseLocal {
+ configDisplay = configDisplayLocal
+ }
+ fmt.Fprintln(w, " ✓ Configured project")
+ fmt.Fprintf(w, " %s\n", configDisplay)
+
+ var vercelPromptFn func() (bool, error)
+ if opts.Yes {
+ vercelPromptFn = func() (bool, error) { return true, nil }
+ }
+ if _, err := maybePromptVercelDeploymentDisable(ctx, w, targetFile, vercelPromptFn); err != nil {
+ return err
+ }
+
+ // Ask about telemetry consent (only if not already asked).
+ // --yes skips the interactive prompt but still respects --telemetry=false
+ // and TRACE_TELEMETRY_OPTOUT — it only auto-answers the interactive question.
+ if opts.Yes {
+ if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ settings.Telemetry = &f
+ } else if settings.Telemetry == nil {
+ t := true
+ settings.Telemetry = &t
+ }
+ } else if err := promptTelemetryConsent(settings, opts.Telemetry); err != nil {
+ return fmt.Errorf("telemetry consent: %w", err)
+ }
+ // Save again to persist telemetry choice
+ if err := saveSettings(); err != nil {
+ return fmt.Errorf("failed to save settings: %w", err)
+ }
+
+ if err := strategy.EnsureSetup(ctx); err != nil {
+ return fmt.Errorf("failed to setup strategy: %w", err)
+ }
+
+ if opts.SuppressDoneMessage {
+ // Bootstrap finalize will print its own completion summary after
+ // making the initial commit and pushing.
+ return nil
+ }
+
+ fmt.Fprintln(w, "\nReady.")
+
+ // Note about empty repos at the end, after setup is complete
+ if repo, err := strategy.OpenRepository(ctx); err == nil && strategy.IsEmptyRepository(repo) {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Note: Session checkpoints require at least one commit. To get started,")
+ fmt.Fprintln(w, "commit the configuration files (e.g. .trace/, .claude/).")
+ }
+
+ return nil
+}
+
+// printEnabledStatus prints agents and a hint about `trace agent`.
+func printEnabledStatus(ctx context.Context, w io.Writer) {
+ if displayNames := InstalledAgentDisplayNames(ctx); len(displayNames) > 0 {
+ fmt.Fprintf(w, "Agents: %s\n", strings.Join(displayNames, ", "))
+ }
+ fmt.Fprintln(w, "\nTo add more agents, run `trace agent add `.")
+}
+
+// runEnable sets the enabled flag in settings.
+// Writes to the target file (local by default, project with --project),
+// and also updates the other file if it exists, so they can't get out of sync.
+func runEnable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
+ s, err := LoadTraceSettings(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load settings: %w", err)
+ }
+
+ s.Enabled = true
+
+ if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
+ return err
+ }
+
+ fmt.Fprintln(w, "Trace is now enabled.")
+ printEnabledStatus(ctx, w)
+ return nil
+}
+
+func runDisable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
+ s, err := LoadTraceSettings(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load settings: %w", err)
+ }
+
+ s.Enabled = false
+
+ if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
+ return err
+ }
+
+ fmt.Fprintln(w, "Trace is now disabled.")
+ return nil
+}
+
+// saveEnabledState writes settings to the target file and also updates the
+// other settings file if it exists, preventing local/project from getting
+// out of sync on the enabled field.
+func saveEnabledState(ctx context.Context, s *TraceSettings, useProjectSettings bool) error {
+ if useProjectSettings {
+ if err := SaveTraceSettings(ctx, s); err != nil {
+ return fmt.Errorf("failed to save settings: %w", err)
+ }
+ // Also update local if it exists, so it doesn't override
+ if localExists(ctx) {
+ if err := SaveTraceSettingsLocal(ctx, s); err != nil {
+ return fmt.Errorf("failed to save local settings: %w", err)
+ }
+ }
+ } else {
+ if err := SaveTraceSettingsLocal(ctx, s); err != nil {
+ return fmt.Errorf("failed to save local settings: %w", err)
+ }
+ }
+ return nil
+}
+
+// localExists checks if settings.local.json exists.
+func localExists(ctx context.Context) bool {
+ localFile := settings.TraceSettingsLocalFile
+ if abs, err := paths.AbsPath(ctx, localFile); err == nil {
+ localFile = abs
+ }
+ _, err := os.Stat(localFile)
+ return err == nil
+}
+
+// runRemoveAgent removes hooks for a specific agent.
+func runRemoveAgent(ctx context.Context, w io.Writer, name string) error {
+ ag, err := agent.Get(types.AgentName(name))
+ if err != nil {
+ printWrongAgentError(w, name)
+ return NewSilentError(errors.New("wrong agent name"))
+ }
+
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ return fmt.Errorf("agent %s does not support hooks", name)
+ }
+
+ if !hookAgent.AreHooksInstalled(ctx) {
+ fmt.Fprintf(w, "%s hooks are not installed.\n", ag.Type())
+ return nil
+ }
+
+ if err := hookAgent.UninstallHooks(ctx); err != nil {
+ return fmt.Errorf("failed to remove %s hooks: %w", ag.Type(), err)
+ }
+
+ fmt.Fprintf(w, "Removed %s hooks.\n", ag.Type())
+ return nil
+}
+
+// DisabledMessage is the message shown when Trace is disabled
+const DisabledMessage = "Trace is disabled. Run `trace enable` to re-enable."
+
+// checkDisabledGuard checks if Trace is disabled and prints a message if so.
+// Returns true if the caller should exit (i.e., Trace is disabled).
+// On error reading settings, defaults to enabled (returns false).
+func checkDisabledGuard(ctx context.Context, w io.Writer) bool {
+ enabled, err := IsEnabled(ctx)
+ if err != nil {
+ // Default to enabled on error
+ return false
+ }
+ if !enabled {
+ fmt.Fprintln(w, DisabledMessage)
+ return true
+ }
+ return false
+}
+
+// uninstallDeselectedAgentHooks removes hooks for agents that were previously
+// installed but are not in the selected list. This handles the case where a user
+// re-runs `trace enable` and deselects an agent.
+func uninstallDeselectedAgentHooks(ctx context.Context, w io.Writer, selectedAgents []agent.Agent) error {
+ installedNames := GetAgentsWithHooksInstalled(ctx)
+ if len(installedNames) == 0 {
+ return nil
+ }
+
+ selectedSet := make(map[types.AgentName]struct{}, len(selectedAgents))
+ for _, ag := range selectedAgents {
+ selectedSet[ag.Name()] = struct{}{}
+ }
+
+ var errs []error
+ for _, name := range installedNames {
+ if _, selected := selectedSet[name]; selected {
+ continue
+ }
+ ag, err := agent.Get(name)
+ if err != nil {
+ continue
+ }
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ continue
+ }
+ if err := hookAgent.UninstallHooks(ctx); err != nil {
+ errs = append(errs, fmt.Errorf("failed to uninstall %s hooks: %w", ag.Type(), err))
+ } else {
+ fmt.Fprintf(w, "Removed %s hooks\n", ag.Type())
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// setupAgentHooks sets up hooks for a given agent.
+// Returns the number of hooks installed (0 if already installed).
+func setupAgentHooks(ctx context.Context, w io.Writer, ag agent.Agent, localDev, forceHooks bool) (int, error) {
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ return 0, fmt.Errorf("agent %s does not support hooks", ag.Name())
+ }
+
+ count, err := hookAgent.InstallHooks(ctx, localDev, forceHooks)
+ if err != nil {
+ return 0, fmt.Errorf("failed to install %s hooks: %w", ag.Name(), err)
+ }
+
+ scaffoldResult, err := scaffoldSearchSubagent(ctx, ag)
+ if err != nil {
+ return 0, fmt.Errorf("failed to scaffold %s search subagent: %w", ag.Name(), err)
+ }
+ reportSearchSubagentScaffold(w, ag, scaffoldResult)
+
+ return count, nil
+}
+
+// detectOrSelectAgent tries to auto-detect agents, or prompts the user to select.
+// Returns the detected/selected agents and any error.
+//
+// On first run (no hooks installed):
+// - Single detected built-in agent: used automatically
+// - Single detected external agent: interactive multi-select prompt
+// - Multiple/no detected agents: interactive multi-select prompt
+//
+// On re-run (hooks already installed):
+// - Always shows the interactive multi-select
+// - Pre-selects only agents that have hooks installed (respects prior deselection)
+//
+// selectFn overrides the interactive prompt for testing. When nil, the real form is used.
+// It receives available agent names and returns the selected names.
+func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(available []string) ([]string, error)) ([]agent.Agent, error) {
+ // Check for agents with hooks already installed (re-run detection)
+ installedAgentNames := GetAgentsWithHooksInstalled(ctx)
+ hasInstalledHooks := len(installedAgentNames) > 0
+
+ // Try auto-detection
+ detected := agent.DetectAll(ctx)
+
+ // First run: use existing auto-detect shortcuts
+ if !hasInstalledHooks {
+ switch {
+ case len(detected) == 1:
+ if isBuiltInAgent(detected[0]) {
+ // When a selectFn is provided (e.g. --yes), skip the single-agent
+ // shortcut so the caller's selection logic runs instead.
+ if selectFn == nil {
+ fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
+ return detected, nil
+ }
+ }
+
+ case len(detected) > 1:
+ agentTypes := make([]string, 0, len(detected))
+ for _, ag := range detected {
+ agentTypes = append(agentTypes, string(ag.Type()))
+ }
+ fmt.Fprintf(w, "Detected multiple agents: %s\n", strings.Join(agentTypes, ", "))
+ fmt.Fprintln(w)
+ }
+ }
+
+ // When no selectFn is provided, check if we can prompt interactively.
+ // A selectFn (e.g. from --yes) bypasses the interactive prompt entirely.
+ if selectFn == nil && !interactive.CanPromptInteractively() {
+ if hasInstalledHooks {
+ // Re-run without TTY — keep currently installed agents
+ agents := make([]agent.Agent, 0, len(installedAgentNames))
+ for _, name := range installedAgentNames {
+ ag, err := agent.Get(name)
+ if err != nil {
+ continue
+ }
+ agents = append(agents, ag)
+ }
+ return agents, nil
+ }
+ if len(detected) > 0 {
+ return detected, nil
+ }
+ defaultAgent := agent.Default()
+ if defaultAgent == nil {
+ return nil, errors.New("no default agent available")
+ }
+ fmt.Fprintf(w, "Agent: %s (use --agent to change)\n\n", defaultAgent.Type())
+ return []agent.Agent{defaultAgent}, nil
+ }
+
+ // Build pre-selection set.
+ // On re-run: only pre-select agents with hooks installed (respect prior deselection).
+ // On first run: pre-select detected built-in agents only.
+ preSelectedSet := make(map[types.AgentName]struct{})
+ if hasInstalledHooks {
+ for _, name := range installedAgentNames {
+ preSelectedSet[name] = struct{}{}
+ }
+ } else {
+ for _, ag := range detected {
+ if isBuiltInAgent(ag) {
+ preSelectedSet[ag.Name()] = struct{}{}
+ }
+ }
+ }
+
+ // Build options from registered agents
+ agentNames := agent.List()
+ options := make([]huh.Option[string], 0, len(agentNames))
+ for _, name := range agentNames {
+ ag, err := agent.Get(name)
+ if err != nil {
+ continue
+ }
+ // Only show agents that support hooks
+ if _, ok := agent.AsHookSupport(ag); !ok {
+ continue
+ }
+ // Skip test-only agents (e.g., Vogon canary)
+ if to, ok := ag.(agent.TestOnly); ok && to.IsTestOnly() {
+ continue
+ }
+ opt := huh.NewOption(string(ag.Type()), string(name))
+ if _, isPreSelected := preSelectedSet[name]; isPreSelected {
+ opt = opt.Selected(true)
+ }
+ options = append(options, opt)
+ }
+
+ if len(options) == 0 {
+ return nil, errors.New("no agents with hook support available")
+ }
+
+ // Collect available agent names for the selector
+ availableNames := make([]string, 0, len(options))
+ for _, opt := range options {
+ availableNames = append(availableNames, opt.Value)
+ }
+
+ var selectedAgentNames []string
+ if selectFn != nil {
+ var err error
+ selectedAgentNames, err = selectFn(availableNames)
+ if err != nil {
+ return nil, err
+ }
+ if len(selectedAgentNames) == 0 {
+ return nil, errors.New("no agents selected")
+ }
+ } else {
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewMultiSelect[string]().
+ Title("Select the agents you want to use").
+ Description("Use space to select, enter to confirm.").
+ Options(options...).
+ Validate(func(selected []string) error {
+ if len(selected) == 0 {
+ return errors.New("please select at least one agent")
+ }
+ return nil
+ }).
+ Value(&selectedAgentNames),
+ ),
+ )
+ if err := form.Run(); err != nil {
+ return nil, fmt.Errorf("agent selection cancelled: %w", err)
+ }
+ }
+
+ selectedAgents := make([]agent.Agent, 0, len(selectedAgentNames))
+ for _, name := range selectedAgentNames {
+ selectedAgent, err := agent.Get(types.AgentName(name))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get selected agent %s: %w", name, err)
+ }
+ selectedAgents = append(selectedAgents, selectedAgent)
+ }
+
+ agentTypes := make([]string, 0, len(selectedAgents))
+ for _, ag := range selectedAgents {
+ agentTypes = append(agentTypes, string(ag.Type()))
+ }
+ fmt.Fprintf(w, " Selected agents: %s\n", strings.Join(agentTypes, ", "))
+ return selectedAgents, nil
+}
+
+func isBuiltInAgent(ag agent.Agent) bool {
+ return !external.IsExternal(ag)
+}
+
+// printAgentError writes an error message followed by available agents and usage.
+func printAgentError(w io.Writer, message string) {
+ agents := agent.List()
+ fmt.Fprintf(w, "%s Available agents:\n", message)
+ fmt.Fprintln(w)
+ for _, a := range agents {
+ suffix := ""
+ if a == agent.DefaultAgentName {
+ suffix = " (default)"
+ }
+ fmt.Fprintf(w, " %s%s\n", a, suffix)
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Usage: trace enable --agent ")
+}
+
+// printMissingAgentError writes a helpful error listing available agents.
+func printMissingAgentError(w io.Writer) {
+ printAgentError(w, "Missing agent name.")
+}
+
+// printWrongAgentError writes a helpful error when an unknown agent name is provided.
+func printWrongAgentError(w io.Writer, name string) {
+ printAgentError(w, fmt.Sprintf("Unknown agent %q.", name))
+}
diff --git a/cli/setup_2_test.go b/cli/setup_2_test.go
new file mode 100644
index 0000000..82dca02
--- /dev/null
+++ b/cli/setup_2_test.go
@@ -0,0 +1,805 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ "github.com/GrayCodeAI/trace/cli/agent/external"
+ _ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+)
+
+func TestAppendShellCompletion(t *testing.T) {
+ tests := []struct {
+ name string
+ rcFileRelPath string
+ completionLine string
+ preExisting string // existing content in rc file; empty means file doesn't exist
+ createParent bool // whether parent dir already exists
+ }{
+ {
+ name: "zsh_new_file",
+ rcFileRelPath: ".zshrc",
+ completionLine: "source <(trace completion zsh)",
+ createParent: true,
+ },
+ {
+ name: "zsh_existing_file",
+ rcFileRelPath: ".zshrc",
+ completionLine: "source <(trace completion zsh)",
+ preExisting: "# existing zshrc content\n",
+ createParent: true,
+ },
+ {
+ name: "fish_no_parent_dir",
+ rcFileRelPath: filepath.Join(".config", "fish", "config.fish"),
+ completionLine: "trace completion fish | source",
+ createParent: false,
+ },
+ {
+ name: "fish_existing_dir",
+ rcFileRelPath: filepath.Join(".config", "fish", "config.fish"),
+ completionLine: "trace completion fish | source",
+ createParent: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ home := t.TempDir()
+ rcFile := filepath.Join(home, tt.rcFileRelPath)
+
+ if tt.createParent {
+ if err := os.MkdirAll(filepath.Dir(rcFile), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tt.preExisting != "" {
+ if err := os.WriteFile(rcFile, []byte(tt.preExisting), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := appendShellCompletion(rcFile, tt.completionLine); err != nil {
+ t.Fatalf("appendShellCompletion() error: %v", err)
+ }
+
+ // Verify the file was created and contains the completion line.
+ data, err := os.ReadFile(rcFile)
+ if err != nil {
+ t.Fatalf("reading rc file: %v", err)
+ }
+ content := string(data)
+
+ if !strings.Contains(content, shellCompletionComment) {
+ t.Errorf("rc file missing comment %q", shellCompletionComment)
+ }
+ if !strings.Contains(content, tt.completionLine) {
+ t.Errorf("rc file missing completion line %q", tt.completionLine)
+ }
+ if tt.preExisting != "" && !strings.HasPrefix(content, tt.preExisting) {
+ t.Errorf("pre-existing content was overwritten")
+ }
+
+ // Verify parent directory permissions.
+ info, err := os.Stat(filepath.Dir(rcFile))
+ if err != nil {
+ t.Fatalf("stat parent dir: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatal("parent path is not a directory")
+ }
+ })
+ }
+}
+
+func TestRemoveTraceDirectory_NotExists(t *testing.T) {
+ setupTestDir(t)
+
+ // Should not error when directory doesn't exist
+ if err := removeTraceDirectory(context.Background()); err != nil {
+ t.Fatalf("removeTraceDirectory(context.Background()) should not error when directory doesn't exist: %v", err)
+ }
+}
+
+func TestPrintMissingAgentError(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ printMissingAgentError(&buf)
+ output := buf.String()
+
+ if !strings.Contains(output, "Missing agent name") {
+ t.Error("expected 'Missing agent name' in output")
+ }
+ for _, a := range agent.List() {
+ if !strings.Contains(output, string(a)) {
+ t.Errorf("expected agent %q listed in output", a)
+ }
+ }
+ if !strings.Contains(output, "(default)") {
+ t.Error("expected default annotation in output")
+ }
+ if !strings.Contains(output, "Usage: trace enable --agent") {
+ t.Error("expected usage line in output")
+ }
+}
+
+func TestPrintWrongAgentError(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ printWrongAgentError(&buf, "not-an-agent")
+ output := buf.String()
+
+ if !strings.Contains(output, `Unknown agent "not-an-agent"`) {
+ t.Error("expected unknown agent name in output")
+ }
+ for _, a := range agent.List() {
+ if !strings.Contains(output, string(a)) {
+ t.Errorf("expected agent %q listed in output", a)
+ }
+ }
+ if !strings.Contains(output, "(default)") {
+ t.Error("expected default annotation in output")
+ }
+ if !strings.Contains(output, "Usage: trace enable --agent") {
+ t.Error("expected usage line in output")
+ }
+}
+
+func TestEnableCmd_AgentFlagNoValue(t *testing.T) {
+ setupTestRepo(t)
+
+ cmd := newEnableCmd()
+ var stderr bytes.Buffer
+ cmd.SetErr(&stderr)
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--agent"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error when --agent is used without a value")
+ }
+
+ output := stderr.String()
+ if !strings.Contains(output, "Missing agent name") {
+ t.Errorf("expected helpful error message, got: %s", output)
+ }
+ if !strings.Contains(output, string(agent.DefaultAgentName)) {
+ t.Errorf("expected default agent listed, got: %s", output)
+ }
+ if strings.Contains(output, "flag needs an argument") {
+ t.Error("should not contain default cobra/pflag error message")
+ }
+}
+
+func TestEnableCmd_AgentFlagEmptyValue(t *testing.T) {
+ setupTestRepo(t)
+
+ cmd := newEnableCmd()
+ var stderr bytes.Buffer
+ cmd.SetErr(&stderr)
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--agent="})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error when --agent= is used with empty value")
+ }
+
+ output := stderr.String()
+ if !strings.Contains(output, "Missing agent name") {
+ t.Errorf("expected helpful error message, got: %s", output)
+ }
+ if strings.Contains(output, "flag needs an argument") {
+ t.Error("should not contain default cobra/pflag error message")
+ }
+}
+
+func TestEnableUsesSetupFlow(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ args []string
+ agentName string
+ want bool
+ }{
+ {name: "bare enable", args: nil, want: false},
+ {name: "project only", args: []string{"--project"}, want: false},
+ {name: "local only", args: []string{"--local"}, want: false},
+ {name: "force", args: []string{"--force"}, want: true},
+ {name: "local dev", args: []string{"--local-dev"}, want: true},
+ {name: "absolute hook path", args: []string{"--absolute-git-hook-path"}, want: true},
+ {name: "telemetry changed", args: []string{"--telemetry=false"}, want: true},
+ {name: "checkpoint remote", args: []string{"--checkpoint-remote", "github:org/repo"}, want: true},
+ {name: "skip push sessions", args: []string{"--skip-push-sessions"}, want: true},
+ {name: "agent flag", args: []string{"--agent", "claude-code"}, agentName: "claude-code", want: true},
+ {name: "yes flag", args: []string{"--yes"}, want: true},
+ {name: "yes short flag", args: []string{"-y"}, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ cmd := newEnableCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs(tt.args)
+ if err := cmd.ParseFlags(tt.args); err != nil {
+ t.Fatalf("ParseFlags() error = %v", err)
+ }
+
+ if got := enableUsesSetupFlow(cmd, tt.agentName); got != tt.want {
+ t.Fatalf("enableUsesSetupFlow(%v, %q) = %v, want %v", tt.args, tt.agentName, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestEnableCmd_ForceOnConfiguredRepo_UsesConfigureFlow(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ cmd := newEnableCmd()
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable --force error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
+ t.Fatalf("expected enable --force to route to configure flow, got: %s", output)
+ }
+ if strings.Contains(output, "Trace is already enabled.") {
+ t.Fatalf("expected enable --force to avoid the lightweight re-enable path, got: %s", output)
+ }
+}
+
+func TestEnableCmd_ForceOnConfiguredDisabledRepo_Reenables(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsDisabled)
+ writeClaudeHooksFixture(t)
+
+ cmd := newEnableCmd()
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--force"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable --force error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
+ t.Fatalf("expected enable --force to route through manage agents before enabling, got: %s", output)
+ }
+ if !strings.Contains(output, "Trace is now enabled.") {
+ t.Fatalf("expected enable --force to still enable the repo, got: %s", output)
+ }
+
+ enabled, err := IsEnabled(context.Background())
+ if err != nil {
+ t.Fatalf("IsEnabled() error = %v", err)
+ }
+ if !enabled {
+ t.Fatal("expected repo to be enabled after enable --force")
+ }
+}
+
+func TestEnableCmd_ForceAndStrategyFlagsOnConfiguredDisabledRepo_ReenablesAndUpdatesSettings(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsDisabled)
+ writeClaudeHooksFixture(t)
+
+ cmd := newEnableCmd()
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--force", "--checkpoint-remote", "github:org/repo", "--skip-push-sessions"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable with force and strategy flags error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Settings updated") {
+ t.Fatalf("expected strategy flags to be applied, got: %s", output)
+ }
+ if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
+ t.Fatalf("expected force handling to still reach manage agents, got: %s", output)
+ }
+ if !strings.Contains(output, "Trace is now enabled.") {
+ t.Fatalf("expected repo to be enabled after updating settings, got: %s", output)
+ }
+
+ enabled, err := IsEnabled(context.Background())
+ if err != nil {
+ t.Fatalf("IsEnabled() error = %v", err)
+ }
+ if !enabled {
+ t.Fatal("expected repo to be enabled after enable with strategy flags")
+ }
+
+ s, err := LoadTraceSettings(context.Background())
+ if err != nil {
+ t.Fatalf("LoadTraceSettings() error = %v", err)
+ }
+ if got := s.StrategyOptions["push_sessions"]; got != false {
+ t.Fatalf("push_sessions = %v, want false", got)
+ }
+ checkpointRemote, ok := s.StrategyOptions["checkpoint_remote"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("checkpoint_remote = %#v, want map", s.StrategyOptions["checkpoint_remote"])
+ }
+ if checkpointRemote["provider"] != "github" || checkpointRemote["repo"] != "org/repo" {
+ t.Fatalf("checkpoint_remote = %#v, want github/org/repo", checkpointRemote)
+ }
+}
+
+// Tests for detectOrSelectAgent
+
+func TestDetectOrSelectAgent_AgentDetected(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+
+ // Create .claude directory so Claude Code agent is detected
+ if err := os.MkdirAll(".claude", 0o755); err != nil {
+ t.Fatalf("Failed to create .claude directory: %v", err)
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should detect Claude Code
+ if len(agents) != 1 {
+ t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
+ }
+ if agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Errorf("detectOrSelectAgent() agent name = %v, want %v", agents[0].Name(), agent.AgentNameClaudeCode)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Detected agent:") {
+ t.Errorf("Expected output to contain 'Detected agent:', got: %s", output)
+ }
+ if !strings.Contains(output, string(agent.AgentTypeClaudeCode)) {
+ t.Errorf("Expected output to contain '%s', got: %s", agent.AgentTypeClaudeCode, output)
+ }
+}
+
+func TestDetectOrSelectAgent_GeminiDetected(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+
+ // Create .gemini directory so Gemini agent is detected
+ if err := os.MkdirAll(".gemini", 0o755); err != nil {
+ t.Fatalf("Failed to create .gemini directory: %v", err)
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should detect Gemini
+ if len(agents) != 1 {
+ t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
+ }
+ if agents[0].Name() != agent.AgentNameGemini {
+ t.Errorf("detectOrSelectAgent() agent name = %v, want %v", agents[0].Name(), agent.AgentNameGemini)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Detected agent:") {
+ t.Errorf("Expected output to contain 'Detected agent:', got: %s", output)
+ }
+}
+
+func TestDetectOrSelectAgent_OnlyExternalDetected_WithTTY_PromptsUser(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir, t.Setenv, and global agent registration
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ externalAgentName := "ext-prompt-pi"
+ externalDir := t.TempDir()
+ writeExternalAgentBinary(t, externalDir, externalAgentName)
+ t.Setenv("TRACE_TEST_EXTERNAL_PRESENT", "1")
+ t.Setenv("PATH", externalDir)
+
+ external.DiscoverAndRegisterAlways(context.Background())
+
+ var receivedAvailable []string
+ selectFn := func(available []string) ([]string, error) {
+ receivedAvailable = available
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ if len(receivedAvailable) == 0 {
+ t.Fatal("Expected interactive prompt when only an external agent is detected")
+ }
+ if !slices.Contains(receivedAvailable, externalAgentName) {
+ t.Fatalf("Expected external agent %q in options, got %v", externalAgentName, receivedAvailable)
+ }
+ if !slices.Contains(receivedAvailable, string(agent.AgentNameClaudeCode)) {
+ t.Fatalf("Expected built-in agent options alongside external agent, got %v", receivedAvailable)
+ }
+ if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Fatalf("Expected selected Claude Code agent, got %v", agents)
+ }
+ if strings.Contains(buf.String(), "Detected agent:") {
+ t.Errorf("Expected external-only detection to prompt instead of auto-selecting, got output: %s", buf.String())
+ }
+}
+
+func TestIsBuiltInAgent_ExternalAgent_False(t *testing.T) {
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ setupTestRepo(t)
+
+ externalAgentName := "ext-preselect-pi"
+ externalDir := t.TempDir()
+ writeExternalAgentBinary(t, externalDir, externalAgentName)
+ t.Setenv("TRACE_TEST_EXTERNAL_PRESENT", "1")
+ t.Setenv("PATH", externalDir)
+
+ external.DiscoverAndRegisterAlways(context.Background())
+
+ externalAgent, err := agent.Get(types.AgentName(externalAgentName))
+ if err != nil {
+ t.Fatalf("failed to get external agent %q: %v", externalAgentName, err)
+ }
+
+ if isBuiltInAgent(externalAgent) {
+ t.Fatalf("expected external agent %q to not be treated as built-in", externalAgentName)
+ }
+}
+
+func TestIsBuiltInAgent_BuiltInAgent_True(t *testing.T) {
+ t.Parallel()
+
+ claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
+ if err != nil {
+ t.Fatalf("failed to get claude agent: %v", err)
+ }
+
+ if !isBuiltInAgent(claudeAgent) {
+ t.Fatal("expected built-in agent to be treated as built-in")
+ }
+}
+
+func TestDetectOrSelectAgent_NoDetection_NoTTY_FallsBackToDefault(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+
+ // No .claude or .gemini directory - detection will fail
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should fall back to default agent (Claude Code)
+ if len(agents) != 1 {
+ t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
+ }
+ if agents[0].Name() != agent.DefaultAgentName {
+ t.Errorf("detectOrSelectAgent() agent name = %v, want default %v", agents[0].Name(), agent.DefaultAgentName)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Agent:") {
+ t.Errorf("Expected output to contain 'Agent:', got: %s", output)
+ }
+ if !strings.Contains(output, "(use --agent to change)") {
+ t.Errorf("Expected output to contain '(use --agent to change)', got: %s", output)
+ }
+}
+
+func TestDetectOrSelectAgent_NoDetection_WithTTY_ShowsPromptMessages(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // No .claude or .gemini directory - detection will fail
+
+ // Inject selector to avoid blocking on interactive form.Run().
+ // The selector receives available agent names so tests can validate the options.
+ selectFn := func(available []string) ([]string, error) {
+ if len(available) == 0 {
+ t.Error("selectFn received no available agents")
+ }
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should return the mock-selected agent
+ if len(agents) != 1 {
+ t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
+ }
+ if agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Errorf("detectOrSelectAgent() agent = %v, want %v", agents[0].Name(), agent.AgentNameClaudeCode)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Selected agents:") {
+ t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
+ }
+}
+
+func TestDetectOrSelectAgent_SelectionCancelled(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ selectFn := func(_ []string) ([]string, error) {
+ return nil, errors.New("user cancelled")
+ }
+
+ var buf bytes.Buffer
+ _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err == nil {
+ t.Fatal("expected error when selection is cancelled")
+ }
+ if !strings.Contains(err.Error(), "user cancelled") {
+ t.Errorf("expected 'user cancelled' in error, got: %v", err)
+ }
+}
+
+func TestDetectOrSelectAgent_NoneSelected(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{}, nil // user deselected everything
+ }
+
+ var buf bytes.Buffer
+ _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err == nil {
+ t.Fatal("expected error when no agents selected")
+ }
+ if !strings.Contains(err.Error(), "no agents selected") {
+ t.Errorf("expected 'no agents selected' in error, got: %v", err)
+ }
+}
+
+func TestDetectOrSelectAgent_BothDirectoriesExist_PromptsUser(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // Create both .claude and .gemini directories
+ if err := os.MkdirAll(".claude", 0o755); err != nil {
+ t.Fatalf("Failed to create .claude directory: %v", err)
+ }
+ if err := os.MkdirAll(".gemini", 0o755); err != nil {
+ t.Fatalf("Failed to create .gemini directory: %v", err)
+ }
+
+ // Inject selector — receives available names, returns both
+ selectFn := func(available []string) ([]string, error) {
+ if len(available) < 2 {
+ t.Errorf("expected at least 2 available agents, got %d", len(available))
+ }
+ return []string{string(agent.AgentNameClaudeCode), string(agent.AgentNameGemini)}, nil
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should return both selected agents
+ if len(agents) != 2 {
+ t.Fatalf("detectOrSelectAgent() returned %d agents, want 2", len(agents))
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Detected multiple agents:") {
+ t.Errorf("Expected output to contain 'Detected multiple agents:', got: %s", output)
+ }
+ if !strings.Contains(output, "Claude Code") {
+ t.Errorf("Expected output to mention Claude Code, got: %s", output)
+ }
+ if !strings.Contains(output, "Gemini CLI") {
+ t.Errorf("Expected output to mention Gemini CLI, got: %s", output)
+ }
+ if !strings.Contains(output, "Selected agents:") {
+ t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
+ }
+}
+
+func TestDetectOrSelectAgent_BothDirectoriesExist_NoTTY_UsesAll(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+
+ // Create both .claude and .gemini directories
+ if err := os.MkdirAll(".claude", 0o755); err != nil {
+ t.Fatalf("Failed to create .claude directory: %v", err)
+ }
+ if err := os.MkdirAll(".gemini", 0o755); err != nil {
+ t.Fatalf("Failed to create .gemini directory: %v", err)
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // With no TTY and multiple detected, should return all detected agents
+ if len(agents) != 2 {
+ t.Errorf("detectOrSelectAgent() returned %d agents, want 2", len(agents))
+ }
+}
+
+// writeClaudeHooksFixture writes a minimal .claude/settings.json with Trace hooks installed.
+// Only the Stop hook is needed — AreHooksInstalled() checks for it first.
+func writeClaudeHooksFixture(t *testing.T) {
+ t.Helper()
+ if err := os.MkdirAll(".claude", 0o755); err != nil {
+ t.Fatalf("Failed to create .claude directory: %v", err)
+ }
+ hooksJSON := `{
+ "hooks": {
+ "Stop": [{"hooks": [{"type": "command", "command": "trace hooks claude-code stop"}]}]
+ }
+ }`
+ if err := os.WriteFile(".claude/settings.json", []byte(hooksJSON), 0o644); err != nil {
+ t.Fatalf("Failed to write .claude/settings.json: %v", err)
+ }
+}
+
+// writeGeminiHooksFixture writes a minimal .gemini/settings.json with Trace hooks installed.
+// AreHooksInstalled() checks for any hook command starting with "trace ".
+func writeGeminiHooksFixture(t *testing.T) {
+ t.Helper()
+ if err := os.MkdirAll(".gemini", 0o755); err != nil {
+ t.Fatalf("Failed to create .gemini directory: %v", err)
+ }
+ hooksJSON := `{
+ "hooks": {
+ "enabled": true,
+ "SessionStart": [{"hooks": [{"type": "command", "command": "trace hooks gemini session-start"}]}]
+ }
+ }`
+ if err := os.WriteFile(".gemini/settings.json", []byte(hooksJSON), 0o644); err != nil {
+ t.Fatalf("Failed to write .gemini/settings.json: %v", err)
+ }
+}
+
+func TestDetectOrSelectAgent_ReRun_AlwaysPromptsWithInstalledPreSelected(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // Install Claude Code hooks (simulates a previous `trace enable` run)
+ writeClaudeHooksFixture(t)
+
+ // Verify hooks are detected as installed
+ installed := GetAgentsWithHooksInstalled(context.Background())
+ if len(installed) == 0 {
+ t.Fatal("Expected Claude Code hooks to be detected as installed")
+ }
+
+ // Track what the selector receives
+ var receivedAvailable []string
+ selectFn := func(available []string) ([]string, error) {
+ receivedAvailable = available
+ // User keeps claude-code selected
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should have been prompted (selectFn called) even though only one agent is detected
+ if len(receivedAvailable) == 0 {
+ t.Fatal("Expected interactive prompt to be shown on re-run, but selectFn was not called")
+ }
+
+ // Should return the selected agent
+ if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Errorf("Expected [claude-code], got %v", agents)
+ }
+
+ // Should NOT contain "Detected agent:" (the auto-use message for first run)
+ output := buf.String()
+ if strings.Contains(output, "Detected agent:") {
+ t.Errorf("Re-run should not auto-use agent, but got: %s", output)
+ }
+}
+
+func TestDetectOrSelectAgent_ReRun_NoTTY_KeepsInstalled(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+
+ // Install Claude Code hooks
+ writeClaudeHooksFixture(t)
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should keep currently installed agents without prompting
+ if len(agents) != 1 {
+ t.Fatalf("Expected 1 agent, got %d", len(agents))
+ }
+ if agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Errorf("Expected claude-code, got %v", agents[0].Name())
+ }
+}
+
+// checkClaudeCodeHooksInstalled checks if Claude Code hooks are installed.
+func checkClaudeCodeHooksInstalled() bool {
+ ag, err := agent.Get(agent.AgentNameClaudeCode)
+ if err != nil {
+ return false
+ }
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ return false
+ }
+ return hookAgent.AreHooksInstalled(context.Background())
+}
+
+// checkGeminiCLIHooksInstalled checks if Gemini CLI hooks are installed.
+func checkGeminiCLIHooksInstalled() bool {
+ ag, err := agent.Get(agent.AgentNameGemini)
+ if err != nil {
+ return false
+ }
+ hookAgent, ok := agent.AsHookSupport(ag)
+ if !ok {
+ return false
+ }
+ return hookAgent.AreHooksInstalled(context.Background())
+}
diff --git a/cli/setup_3.go b/cli/setup_3.go
new file mode 100644
index 0000000..d8508ff
--- /dev/null
+++ b/cli/setup_3.go
@@ -0,0 +1,681 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/external"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/strategy"
+ "github.com/GrayCodeAI/trace/cli/vercelconfig"
+
+ "charm.land/huh/v2"
+ "github.com/spf13/cobra"
+)
+
+// setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively.
+// If strategyName is provided, it sets the strategy; otherwise uses default.
+func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Agent, opts EnableOptions) error {
+ agentName := ag.Name()
+ // Check if agent supports hooks
+ if _, ok := agent.AsHookSupport(ag); !ok {
+ return fmt.Errorf("agent %s does not support hooks", agentName)
+ }
+
+ fmt.Fprintf(w, " Agent: %s\n", ag.Type())
+
+ // Install agent hooks (agent hooks don't depend on settings)
+ installedHooks, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks)
+ if err != nil {
+ return fmt.Errorf("failed to setup %s hooks: %w", agentName, err)
+ }
+
+ // Setup .trace directory
+ if _, err := setupTraceDirectory(ctx); err != nil {
+ return fmt.Errorf("failed to setup .trace directory: %w", err)
+ }
+
+ // Load existing settings to preserve other options (like strategy_options.push)
+ settings, err := LoadTraceSettings(ctx)
+ if err != nil {
+ // If we can't load, start with defaults
+ settings = &TraceSettings{}
+ }
+ settings.Enabled = true
+ if opts.LocalDev {
+ settings.LocalDev = true
+ }
+ if opts.AbsoluteGitHookPath {
+ settings.AbsoluteGitHookPath = true
+ }
+
+ // Auto-enable external_agents setting if the agent is external.
+ if external.IsExternal(ag) {
+ settings.ExternalAgents = true
+ }
+
+ opts.applyStrategyOptions(settings)
+
+ // Handle telemetry for non-interactive mode
+ // Note: if telemetry is nil (not configured), it defaults to disabled
+ if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ settings.Telemetry = &f
+ }
+
+ targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
+ if err := saveSettingsToTarget(ctx, settings, targetFile); err != nil {
+ return fmt.Errorf("failed to save settings: %w", err)
+ }
+
+ // Use settings values (merged from existing config + flags) for hook installation
+ // This ensures re-running `trace enable --agent X` without flags preserves existing settings
+ if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
+ return fmt.Errorf("failed to install git hooks: %w", err)
+ }
+ strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
+
+ if installedHooks == 0 {
+ msg := fmt.Sprintf("Hooks for %s already installed", ag.Description())
+ if ag.IsPreview() {
+ msg += " (Preview)"
+ }
+ fmt.Fprintf(w, " %s\n", msg)
+ } else {
+ msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description())
+ if ag.IsPreview() {
+ msg += " (Preview)"
+ }
+ fmt.Fprintf(w, " %s\n", msg)
+ }
+
+ fmt.Fprintln(w, " ✓ Configured project")
+ fmt.Fprintf(w, " %s\n", configDisplay)
+
+ if _, err := maybePromptVercelDeploymentDisable(ctx, w, targetFile, nil); err != nil {
+ return err
+ }
+
+ if err := strategy.EnsureSetup(ctx); err != nil {
+ return fmt.Errorf("failed to setup strategy: %w", err)
+ }
+
+ if opts.SuppressDoneMessage {
+ // Bootstrap finalize will print its own completion summary.
+ return nil
+ }
+
+ fmt.Fprintln(w, "\nReady.")
+
+ if repo, err := strategy.OpenRepository(ctx); err == nil && strategy.IsEmptyRepository(repo) {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Note: Session checkpoints require at least one commit. To get started,")
+ fmt.Fprintln(w, "commit the configuration files (e.g. .trace/, .claude/).")
+ }
+
+ return nil
+}
+
+// validateSetupFlags checks that --local and --project flags are not both specified.
+func validateSetupFlags(useLocal, useProject bool) error {
+ if useLocal && useProject {
+ return errors.New("cannot specify both --project and --local")
+ }
+ return nil
+}
+
+// determineSettingsTarget decides whether to write to settings.local.json based on:
+// - Whether settings.json already exists
+// - The --local and --project flags
+// Returns (useLocal, showNotification).
+func determineSettingsTarget(traceDir string, useLocal, useProject bool) (bool, bool) {
+ // Explicit --local flag always uses local settings
+ if useLocal {
+ return true, false
+ }
+
+ // Explicit --project flag always uses project settings
+ if useProject {
+ return false, false
+ }
+
+ // No flags specified - check if settings file exists
+ settingsPath := filepath.Join(traceDir, paths.SettingsFileName)
+ if _, err := os.Stat(settingsPath); err == nil {
+ // Settings file exists - auto-redirect to local with notification
+ return true, true
+ }
+
+ // Settings file doesn't exist - create it
+ return false, false
+}
+
+// setupTraceDirectory creates the .trace directory and gitignore.
+// Returns true if the directory was created, false if it already existed.
+func setupTraceDirectory(ctx context.Context) (bool, error) { //nolint:unparam // already present in codebase
+ // Get absolute path for the .trace directory
+ traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
+ if err != nil {
+ traceDirAbs = paths.TraceDir // Fallback to relative
+ }
+
+ // Check if directory already exists
+ created := false
+ if _, err := os.Stat(traceDirAbs); os.IsNotExist(err) {
+ created = true
+ }
+
+ // Create .trace directory
+ //nolint:gosec // G301: Project directory needs standard permissions for git
+ if err := os.MkdirAll(traceDirAbs, 0o755); err != nil {
+ return false, fmt.Errorf("failed to create .trace directory: %w", err)
+ }
+
+ // Create/update .gitignore with all required entries
+ if err := strategy.EnsureTraceGitignore(ctx); err != nil {
+ return false, fmt.Errorf("failed to setup .gitignore: %w", err)
+ }
+
+ return created, nil
+}
+
+func newCurlBashPostInstallCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "curl-bash-post-install",
+ Short: "Post-install tasks for curl|bash installer",
+ Hidden: true,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ w := cmd.OutOrStdout()
+ if err := promptShellCompletion(w); err != nil {
+ fmt.Fprintf(w, "Note: Shell completion setup skipped: %v\n", err)
+ }
+ return nil
+ },
+ }
+}
+
+// shellCompletionComment is the comment preceding the completion line
+const shellCompletionComment = "# Trace CLI shell completion"
+
+// errUnsupportedShell is returned when the user's shell is not supported for completion.
+var errUnsupportedShell = errors.New("unsupported shell")
+
+// shellCompletionTarget returns the rc file path and completion lines for the
+// user's current shell.
+func shellCompletionTarget() (shellName, rcFile, completionLine string, err error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", "", "", fmt.Errorf("cannot determine home directory: %w", err)
+ }
+
+ shell := os.Getenv("SHELL")
+ switch {
+ case strings.Contains(shell, "zsh"):
+ return "Zsh",
+ filepath.Join(home, ".zshrc"),
+ "autoload -Uz compinit && compinit && source <(trace completion zsh)",
+ nil
+ case strings.Contains(shell, "bash"):
+ bashRC := filepath.Join(home, ".bashrc")
+ if _, err := os.Stat(filepath.Join(home, ".bash_profile")); err == nil {
+ bashRC = filepath.Join(home, ".bash_profile")
+ }
+ return "Bash",
+ bashRC,
+ "source <(trace completion bash)",
+ nil
+ case strings.Contains(shell, "fish"):
+ return "Fish",
+ filepath.Join(home, ".config", "fish", "config.fish"),
+ "trace completion fish | source",
+ nil
+ default:
+ return "", "", "", errUnsupportedShell
+ }
+}
+
+// promptShellCompletion offers to add shell completion to the user's rc file.
+// Only prompts if completion is not already configured.
+func promptShellCompletion(w io.Writer) error {
+ shellName, rcFile, completionLine, err := shellCompletionTarget()
+ if err != nil {
+ if errors.Is(err, errUnsupportedShell) {
+ fmt.Fprintf(w, "Note: Shell completion not available for your shell. Supported: zsh, bash, fish.\n")
+ return nil
+ }
+ return fmt.Errorf("shell completion: %w", err)
+ }
+
+ if isCompletionConfigured(rcFile) {
+ fmt.Fprintf(w, "✓ Shell completion already configured in %s\n", rcFile)
+ return nil
+ }
+
+ var selected string
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title(fmt.Sprintf("Enable shell completion? (detected: %s)", shellName)).
+ Options(
+ huh.NewOption("Yes", "yes"),
+ huh.NewOption("No", "no"),
+ ).
+ Value(&selected),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ //nolint:nilerr // User cancelled - not a fatal error, just skip
+ return nil
+ }
+
+ if selected != "yes" {
+ return nil
+ }
+
+ if err := appendShellCompletion(rcFile, completionLine); err != nil {
+ return fmt.Errorf("failed to update %s: %w", rcFile, err)
+ }
+
+ fmt.Fprintf(w, "✓ Shell completion added to %s\n", rcFile)
+ fmt.Fprintln(w, " Restart your shell to activate")
+
+ return nil
+}
+
+// isCompletionConfigured checks if shell completion is already in the rc file.
+func isCompletionConfigured(rcFile string) bool {
+ //nolint:gosec // G304: rcFile is constructed from home dir + known filename, not user input
+ content, err := os.ReadFile(rcFile)
+ if err != nil {
+ return false // File doesn't exist or can't read, treat as not configured
+ }
+ return strings.Contains(string(content), "trace completion")
+}
+
+// appendShellCompletion adds the completion line to the rc file.
+func appendShellCompletion(rcFile, completionLine string) error {
+ if err := os.MkdirAll(filepath.Dir(rcFile), 0o700); err != nil {
+ return fmt.Errorf("creating directory: %w", err)
+ }
+ //nolint:gosec // G302: Shell rc files need 0644 for user readability
+ f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("opening file: %w", err)
+ }
+ defer f.Close()
+
+ _, err = f.WriteString("\n" + shellCompletionComment + "\n" + completionLine + "\n")
+ if err != nil {
+ return fmt.Errorf("writing completion: %w", err)
+ }
+ return nil
+}
+
+// promptTelemetryConsent asks the user if they want to enable telemetry.
+// It modifies settings.Telemetry based on the user's choice or flags.
+// The caller is responsible for saving settings.
+func promptTelemetryConsent(settings *TraceSettings, telemetryFlag bool) error {
+ // Handle --telemetry=false flag first (always overrides existing setting)
+ if !telemetryFlag {
+ f := false
+ settings.Telemetry = &f
+ return nil
+ }
+
+ // Skip if already asked
+ if settings.Telemetry != nil {
+ return nil
+ }
+
+ // Skip if env var disables telemetry (record as disabled)
+ if os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ settings.Telemetry = &f
+ return nil
+ }
+
+ consent := true // Default to Yes
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Help improve Trace CLI?").
+ Description("Share anonymous usage data. No code or personal info collected.").
+ Affirmative("Yes").
+ Negative("No").
+ Value(&consent),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ return fmt.Errorf("telemetry prompt: %w", err)
+ }
+
+ settings.Telemetry = &consent
+ return nil
+}
+
+func maybePromptVercelDeploymentDisable(ctx context.Context, w io.Writer, targetFile string, promptFn func() (bool, error)) (bool, error) {
+ repoRoot, rootErr := paths.WorktreeRoot(ctx)
+ if rootErr == nil {
+ vercelJSONPath := filepath.Join(repoRoot, "vercel.json")
+ hasVercelJSON := false
+ if _, err := os.Stat(vercelJSONPath); err == nil {
+ hasVercelJSON = true
+ } else if !os.IsNotExist(err) {
+ fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check vercel.json: %v\n", err)
+ return false, nil
+ }
+
+ hasVercelProject := hasVercelJSON
+ if !hasVercelProject {
+ for _, path := range []string{
+ filepath.Join(repoRoot, ".vercel"),
+ filepath.Join(repoRoot, "vercel.ts"),
+ } {
+ if _, err := os.Stat(path); err == nil {
+ hasVercelProject = true
+ break
+ } else if !os.IsNotExist(err) {
+ fmt.Fprintf(w, "Note: Skipping Vercel deployment update: could not check %s: %v\n", path, err)
+ return false, nil
+ }
+ }
+ }
+
+ if !hasVercelProject {
+ return false, nil
+ }
+
+ configDisplay := configDisplayProject
+ if targetFile == settings.TraceSettingsLocalFile {
+ configDisplay = configDisplayLocal
+ }
+
+ targetSettingsPath := filepath.Join(repoRoot, targetFile)
+ targetSettings, err := settings.LoadFromFile(targetSettingsPath)
+ if err != nil {
+ return false, fmt.Errorf("load settings: %w", err)
+ }
+ if targetSettings.Vercel {
+ return false, nil
+ }
+
+ if config, alreadyDisabled, loadErr := vercelconfig.Load(vercelJSONPath); loadErr == nil &&
+ config != nil && alreadyDisabled {
+ targetSettings.Vercel = true
+ if err := saveSettingsToTarget(ctx, targetSettings, targetFile); err != nil {
+ return false, fmt.Errorf("save settings: %w", err)
+ }
+ fmt.Fprintf(w, "✓ Updated %s to manage Vercel deployment blocking on `%s`\n", configDisplay, vercelconfig.BranchPattern)
+ return true, nil
+ }
+
+ if promptFn == nil {
+ if !interactive.CanPromptInteractively() {
+ fmt.Fprintf(w, "Note: Vercel detected. Run `trace configure` interactively to disable deployments for `%s` branches.\n", vercelconfig.BranchPattern)
+ return false, nil
+ }
+ promptFn = promptVercelDeploymentDisable
+ }
+
+ disableDeployments, err := promptFn()
+ if err != nil {
+ return false, fmt.Errorf("vercel prompt: %w", err)
+ }
+ if !disableDeployments {
+ return false, nil
+ }
+
+ targetSettings.Vercel = true
+ if err := saveSettingsToTarget(ctx, targetSettings, targetFile); err != nil {
+ return false, fmt.Errorf("save settings: %w", err)
+ }
+
+ fmt.Fprintf(w, "✓ Updated %s to block Vercel deploys of Trace metadata branch\n", configDisplay)
+ return true, nil
+ }
+
+ return false, nil
+}
+
+func promptVercelDeploymentDisable() (bool, error) {
+ disableDeployments := true
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Disable Vercel deployments for Trace metadata branch?").
+ Description("This automatically creates a vercel.json in the Trace metadata branch.").
+ Affirmative("Yes").
+ Negative("No").
+ Value(&disableDeployments),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ return false, fmt.Errorf("run vercel deployment disable form: %w", err)
+ }
+
+ return disableDeployments, nil
+}
+
+// runUninstall completely removes Trace from the repository.
+func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
+ // Check if we're in a git repository
+ if _, err := paths.WorktreeRoot(ctx); err != nil {
+ fmt.Fprintln(errW, "Not a git repository. Nothing to uninstall.")
+ return NewSilentError(errors.New("not a git repository"))
+ }
+
+ // Gather counts for display
+ sessionStateCount := countSessionStates(ctx)
+ shadowBranchCount := countShadowBranches(ctx)
+ gitHooksInstalled := strategy.IsGitHookInstalled(ctx)
+ agentsWithInstalledHooks := GetAgentsWithHooksInstalled(ctx)
+ traceDirExists := checkTraceDirExists(ctx)
+
+ // Check if there's anything to uninstall
+ if !traceDirExists && !gitHooksInstalled && sessionStateCount == 0 &&
+ shadowBranchCount == 0 && len(agentsWithInstalledHooks) == 0 {
+ fmt.Fprintln(w, "Trace is not installed in this repository.")
+ return nil
+ }
+
+ // Show confirmation prompt unless --force
+ if !force {
+ fmt.Fprintln(w, "\nThis will completely remove Trace from this repository:")
+ if traceDirExists {
+ fmt.Fprintln(w, " - .trace/ directory")
+ }
+ if gitHooksInstalled {
+ fmt.Fprintln(w, " - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push)")
+ }
+ if sessionStateCount > 0 {
+ fmt.Fprintf(w, " - Session state files (%d)\n", sessionStateCount)
+ }
+ if shadowBranchCount > 0 {
+ fmt.Fprintf(w, " - Shadow branches (%d)\n", shadowBranchCount)
+ }
+ if len(agentsWithInstalledHooks) > 0 {
+ displayNames := make([]string, 0, len(agentsWithInstalledHooks))
+ for _, name := range agentsWithInstalledHooks {
+ if ag, err := agent.Get(name); err == nil {
+ displayNames = append(displayNames, string(ag.Type()))
+ }
+ }
+ fmt.Fprintf(w, " - Agent hooks (%s)\n", strings.Join(displayNames, ", "))
+ }
+ fmt.Fprintln(w)
+
+ var confirmed bool
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Are you sure you want to uninstall Trace?").
+ Affirmative("Yes, uninstall").
+ Negative("Cancel").
+ Value(&confirmed),
+ ),
+ )
+
+ if err := form.Run(); err != nil {
+ return fmt.Errorf("confirmation cancelled: %w", err)
+ }
+
+ if !confirmed {
+ fmt.Fprintln(w, "Uninstall cancelled.")
+ return nil
+ }
+ }
+
+ fmt.Fprintln(w, "\nUninstalling Trace CLI...")
+
+ // 1. Remove agent hooks (lowest risk)
+ if err := removeAgentHooks(ctx, w); err != nil {
+ fmt.Fprintf(errW, "Warning: failed to remove agent hooks: %v\n", err)
+ }
+
+ // 2. Remove git hooks
+ removed, err := strategy.RemoveGitHook(ctx)
+ if err != nil {
+ fmt.Fprintf(errW, "Warning: failed to remove git hooks: %v\n", err)
+ } else if removed > 0 {
+ fmt.Fprintf(w, " Removed git hooks (%d)\n", removed)
+ }
+
+ // 3. Remove session state files
+ statesRemoved, err := removeAllSessionStates(ctx)
+ if err != nil {
+ fmt.Fprintf(errW, "Warning: failed to remove session states: %v\n", err)
+ } else if statesRemoved > 0 {
+ fmt.Fprintf(w, " Removed session states (%d)\n", statesRemoved)
+ }
+
+ // 4. Remove .trace/ directory
+ if err := removeTraceDirectory(ctx); err != nil {
+ fmt.Fprintf(errW, "Warning: failed to remove .trace directory: %v\n", err)
+ } else if traceDirExists {
+ fmt.Fprintln(w, " Removed .trace directory")
+ }
+
+ // 5. Remove shadow branches
+ branchesRemoved, err := removeAllShadowBranches(ctx)
+ if err != nil {
+ fmt.Fprintf(errW, "Warning: failed to remove shadow branches: %v\n", err)
+ } else if branchesRemoved > 0 {
+ fmt.Fprintf(w, " Removed %d shadow branches\n", branchesRemoved)
+ }
+
+ fmt.Fprintln(w, "\nTrace CLI uninstalled successfully.")
+ return nil
+}
+
+// countSessionStates returns the number of active session state files.
+func countSessionStates(ctx context.Context) int {
+ store, err := session.NewStateStore(ctx)
+ if err != nil {
+ return 0
+ }
+ states, err := store.List(ctx)
+ if err != nil {
+ return 0
+ }
+ return len(states)
+}
+
+// countShadowBranches returns the number of shadow branches.
+func countShadowBranches(ctx context.Context) int {
+ branches, err := strategy.ListShadowBranches(ctx)
+ if err != nil {
+ return 0
+ }
+ return len(branches)
+}
+
+// checkTraceDirExists checks if the .trace directory exists.
+func checkTraceDirExists(ctx context.Context) bool {
+ traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
+ if err != nil {
+ traceDirAbs = paths.TraceDir
+ }
+ _, err = os.Stat(traceDirAbs)
+ return err == nil
+}
+
+// removeAgentHooks removes hooks from all agents that support hooks.
+func removeAgentHooks(ctx context.Context, w io.Writer) error {
+ var errs []error
+ for _, name := range agent.List() {
+ ag, err := agent.Get(name)
+ if err != nil {
+ continue
+ }
+ hs, ok := agent.AsHookSupport(ag)
+ if !ok {
+ continue
+ }
+ wasInstalled := hs.AreHooksInstalled(ctx)
+ if err := hs.UninstallHooks(ctx); err != nil {
+ errs = append(errs, err)
+ } else if wasInstalled {
+ fmt.Fprintf(w, " Removed %s hooks\n", ag.Type())
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// removeAllSessionStates removes all session state files and the directory.
+func removeAllSessionStates(ctx context.Context) (int, error) {
+ store, err := session.NewStateStore(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("failed to create state store: %w", err)
+ }
+
+ // Count states before removing
+ states, err := store.List(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("failed to list session states: %w", err)
+ }
+ count := len(states)
+
+ // Remove the trace directory
+ if err := store.RemoveAll(); err != nil {
+ return 0, fmt.Errorf("failed to remove session states: %w", err)
+ }
+
+ return count, nil
+}
+
+// removeTraceDirectory removes the .trace directory.
+func removeTraceDirectory(ctx context.Context) error {
+ traceDirAbs, err := paths.AbsPath(ctx, paths.TraceDir)
+ if err != nil {
+ traceDirAbs = paths.TraceDir
+ }
+ if err := os.RemoveAll(traceDirAbs); err != nil {
+ return fmt.Errorf("failed to remove .trace directory: %w", err)
+ }
+ return nil
+}
+
+// removeAllShadowBranches removes all shadow branches.
+func removeAllShadowBranches(ctx context.Context) (int, error) {
+ branches, err := strategy.ListShadowBranches(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("failed to list shadow branches: %w", err)
+ }
+ if len(branches) == 0 {
+ return 0, nil
+ }
+ deleted, _, err := strategy.DeleteShadowBranches(ctx, branches)
+ return len(deleted), err
+}
diff --git a/cli/setup_3_test.go b/cli/setup_3_test.go
new file mode 100644
index 0000000..b6f8502
--- /dev/null
+++ b/cli/setup_3_test.go
@@ -0,0 +1,807 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ _ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/settings"
+)
+
+func TestUninstallDeselectedAgentHooks(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+
+ // Install Claude Code hooks
+ writeClaudeHooksFixture(t)
+
+ // Verify hooks are installed
+ if !checkClaudeCodeHooksInstalled() {
+ t.Fatal("Expected Claude Code hooks to be installed before test")
+ }
+
+ // Call uninstallDeselectedAgentHooks with an empty selection (deselect claude-code)
+ var buf bytes.Buffer
+ err := uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{})
+ if err != nil {
+ t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
+ }
+
+ // Hooks should be uninstalled
+ if checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to be uninstalled after deselection")
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Removed") {
+ t.Errorf("Expected output to mention removal, got: %s", output)
+ }
+}
+
+func TestUninstallDeselectedAgentHooks_KeepsSelectedAgents(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+
+ // Install Claude Code hooks
+ writeClaudeHooksFixture(t)
+
+ // Call uninstallDeselectedAgentHooks with claude-code still selected
+ claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
+ if err != nil {
+ t.Fatalf("Failed to get claude-code agent: %v", err)
+ }
+
+ var buf bytes.Buffer
+ err = uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{claudeAgent})
+ if err != nil {
+ t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
+ }
+
+ // Hooks should still be installed
+ if !checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to remain installed when still selected")
+ }
+
+ output := buf.String()
+ if strings.Contains(output, "Removed") {
+ t.Errorf("Should not mention removal when agent is still selected, got: %s", output)
+ }
+}
+
+func TestUninstallDeselectedAgentHooks_MultipleInstalled_DeselectOne(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+
+ // Install both Claude Code and Gemini hooks
+ writeClaudeHooksFixture(t)
+ writeGeminiHooksFixture(t)
+
+ // Verify both are installed
+ installed := GetAgentsWithHooksInstalled(context.Background())
+ if len(installed) < 2 {
+ t.Fatalf("Expected at least 2 agents installed, got %d", len(installed))
+ }
+
+ // Keep only Claude Code selected (deselect Gemini)
+ claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
+ if err != nil {
+ t.Fatalf("Failed to get claude-code agent: %v", err)
+ }
+
+ var buf bytes.Buffer
+ err = uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{claudeAgent})
+ if err != nil {
+ t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
+ }
+
+ // Claude Code hooks should remain
+ if !checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to remain installed")
+ }
+
+ // Gemini hooks should be removed
+ if checkGeminiCLIHooksInstalled() {
+ t.Error("Expected Gemini CLI hooks to be uninstalled after deselection")
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Removed") {
+ t.Errorf("Expected output to mention removal, got: %s", output)
+ }
+}
+
+func TestManageAgents_DeselectRemovesAgent(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+
+ // Install Claude Code hooks
+ writeClaudeHooksFixture(t)
+
+ if !checkClaudeCodeHooksInstalled() {
+ t.Fatal("Expected Claude Code hooks to be installed before test")
+ }
+
+ // Deselect claude-code, select gemini instead
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameGemini)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ output := buf.String()
+
+ // Claude Code hooks should be removed
+ if checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to be uninstalled after deselection")
+ }
+
+ if !strings.Contains(output, "Removed agents") {
+ t.Errorf("Expected output to mention removed agents, got: %s", output)
+ }
+}
+
+func TestManageAgents_DeselectAll_RemovesAllAndShowsGuidance(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ if !checkClaudeCodeHooksInstalled() {
+ t.Fatal("Expected Claude Code hooks to be installed before test")
+ }
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "All agents have been removed.") {
+ t.Errorf("Expected 'All agents have been removed.' message, got: %s", output)
+ }
+ if !strings.Contains(output, "trace agent add") {
+ t.Errorf("Expected guidance on how to re-add agents, got: %s", output)
+ }
+
+ if checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to be uninstalled after deselecting all")
+ }
+}
+
+func TestManageAgents_NoChanges(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ // Keep the same selection
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ if !strings.Contains(buf.String(), "No changes made.") {
+ t.Errorf("Expected 'No changes made.' output, got: %s", buf.String())
+ }
+}
+
+func TestManageAgents_NoChanges_StillPersistsVercelSetting(t *testing.T) {
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ if err := os.WriteFile("vercel.json", []byte(`{
+ "git": {
+ "deploymentEnabled": {
+ "trace/**": false
+ }
+ }
+}`), 0o644); err != nil {
+ t.Fatalf("write vercel.json: %v", err)
+ }
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ if strings.Contains(buf.String(), "No changes made.") {
+ t.Fatalf("did not expect no-op output when settings changed, got: %s", buf.String())
+ }
+ if !strings.Contains(buf.String(), ".trace/settings.json") {
+ t.Fatalf("expected settings update output, got: %s", buf.String())
+ }
+
+ s, err := settings.Load(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if !s.Vercel {
+ t.Fatal("expected vercel setting to be enabled")
+ }
+}
+
+func TestManageAgents_ForceReinstallsSelectedAgentHooks(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ // Simulate a stale or locally modified Trace-managed Claude hook.
+ modifiedHooksJSON := `{
+ "hooks": {
+ "Stop": [{"hooks": [{"type": "command", "command": "trace hooks claude-code stop --stale"}]}]
+ }
+ }`
+ if err := os.WriteFile(".claude/settings.json", []byte(modifiedHooksJSON), 0o644); err != nil {
+ t.Fatalf("Failed to mutate .claude/settings.json: %v", err)
+ }
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{ForceHooks: true}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ data, err := os.ReadFile(".claude/settings.json")
+ if err != nil {
+ t.Fatalf("Failed to read .claude/settings.json: %v", err)
+ }
+ content := string(data)
+
+ if strings.Contains(content, "stop --stale") {
+ t.Errorf("Expected force reinstall to rewrite stale Claude hook, got: %s", content)
+ }
+ if !strings.Contains(content, "trace hooks claude-code stop") {
+ t.Errorf("Expected force reinstall to restore canonical Claude hook, got: %s", content)
+ }
+ if strings.Contains(buf.String(), "No changes made.") {
+ t.Errorf("Force reinstall should not be treated as no-op, got: %s", buf.String())
+ }
+}
+
+func TestManageAgents_ForceReportsReinstalledAgentsSeparately(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{ForceHooks: true}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ if !strings.Contains(buf.String(), "Reinstalled agents") {
+ t.Errorf("Expected force reinstall summary to mention reinstalled agents, got: %s", buf.String())
+ }
+ if strings.Contains(buf.String(), "Added agents") {
+ t.Errorf("Force reinstall should not be reported as added agents, got: %s", buf.String())
+ }
+}
+
+func TestManageAgents_AddAndRemove(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+ writeSettings(t, testSettingsEnabled)
+
+ // Install Claude Code hooks
+ writeClaudeHooksFixture(t)
+
+ // Deselect claude-code, add gemini
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{string(agent.AgentNameGemini)}, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
+ if err != nil {
+ t.Fatalf("runManageAgents() error = %v", err)
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Added agents") {
+ t.Errorf("Expected 'Added agents' in output, got: %s", output)
+ }
+ if !strings.Contains(output, "Removed agents") {
+ t.Errorf("Expected 'Removed agents' in output, got: %s", output)
+ }
+
+ // Verify hooks on disk: Claude removed, Gemini added
+ if checkClaudeCodeHooksInstalled() {
+ t.Error("Expected Claude Code hooks to be uninstalled after deselection")
+ }
+ if !checkGeminiCLIHooksInstalled() {
+ t.Error("Expected Gemini CLI hooks to be installed after selection")
+ }
+}
+
+func TestMaybePromptVercelDeploymentDisable_MergesExistingConfig(t *testing.T) {
+ setupTestRepo(t)
+
+ requireWriteFile := func(path, content string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+ }
+
+ requireWriteFile("vercel.json", `{
+ "cleanUrls": true,
+ "git": {
+ "deploymentEnabled": {
+ "main": true
+ }
+ }
+}`)
+
+ var prompted bool
+ var buf bytes.Buffer
+ changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
+ prompted = true
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
+ }
+ if !changed {
+ t.Fatal("expected Vercel setting change")
+ }
+ if !prompted {
+ t.Fatal("expected Vercel prompt to run")
+ }
+
+ projectSettings, err := settings.Load(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if !projectSettings.Vercel {
+ t.Fatal("expected vercel setting to be enabled")
+ }
+}
+
+func TestMaybePromptVercelDeploymentDisable_CreatesConfigWhenVercelDetected(t *testing.T) {
+ setupTestRepo(t)
+
+ if err := os.MkdirAll(".vercel", 0o755); err != nil {
+ t.Fatalf("mkdir .vercel: %v", err)
+ }
+
+ var buf bytes.Buffer
+ changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
+ }
+ if !changed {
+ t.Fatal("expected Vercel setting change")
+ }
+
+ projectSettings, err := settings.Load(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if !projectSettings.Vercel {
+ t.Fatal("expected vercel setting to be enabled")
+ }
+}
+
+func TestMaybePromptVercelDeploymentDisable_SkipsPromptWhenAlreadyDisabledInVercelJSON(t *testing.T) {
+ setupTestRepo(t)
+
+ if err := os.WriteFile("vercel.json", []byte(`{
+ "git": {
+ "deploymentEnabled": {
+ "trace/**": false
+ }
+ }
+}`), 0o644); err != nil {
+ t.Fatalf("write vercel.json: %v", err)
+ }
+
+ promptCalled := false
+ var buf bytes.Buffer
+ changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
+ promptCalled = true
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
+ }
+ if !changed {
+ t.Fatal("expected Vercel setting change from existing vercel.json")
+ }
+ if promptCalled {
+ t.Fatal("expected Vercel prompt to be skipped when already configured")
+ }
+ if !strings.Contains(buf.String(), ".trace/settings.json") {
+ t.Fatalf("expected settings update output, got %q", buf.String())
+ }
+
+ projectSettings, err := settings.Load(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if !projectSettings.Vercel {
+ t.Fatal("expected vercel setting to be enabled from existing vercel.json")
+ }
+}
+
+func TestMaybePromptVercelDeploymentDisable_WritesLocalSettingsWhenRequested(t *testing.T) {
+ setupTestRepo(t)
+
+ if err := os.MkdirAll(filepath.Dir(settings.TraceSettingsLocalFile), 0o755); err != nil {
+ t.Fatalf("mkdir settings dir: %v", err)
+ }
+ if err := os.WriteFile("vercel.json", []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("write vercel.json: %v", err)
+ }
+
+ var buf bytes.Buffer
+ changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsLocalFile, func() (bool, error) {
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
+ }
+ if !changed {
+ t.Fatal("expected Vercel setting change")
+ }
+ if !strings.Contains(buf.String(), settings.TraceSettingsLocalFile) {
+ t.Fatalf("expected local settings update output, got %q", buf.String())
+ }
+
+ localSettingsPath := filepath.Join(".", settings.TraceSettingsLocalFile)
+ localSettings, err := settings.LoadFromFile(localSettingsPath)
+ if err != nil {
+ t.Fatalf("load local settings: %v", err)
+ }
+ if !localSettings.Vercel {
+ t.Fatal("expected vercel setting in local settings")
+ }
+
+ projectSettingsPath := filepath.Join(".", settings.TraceSettingsFile)
+ projectSettings, err := settings.LoadFromFile(projectSettingsPath)
+ if err != nil {
+ t.Fatalf("load project settings: %v", err)
+ }
+ if projectSettings.Vercel {
+ t.Fatal("expected project settings to remain unchanged")
+ }
+}
+
+func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentAvailableNotPreSelected(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // Simulate: Claude Code hooks installed from a previous run
+ writeClaudeHooksFixture(t)
+
+ // Simulate: user added .gemini directory since last enable (detected but not installed)
+ if err := os.MkdirAll(".gemini", 0o755); err != nil {
+ t.Fatalf("Failed to create .gemini directory: %v", err)
+ }
+
+ // Track which agents the selector receives
+ var receivedAvailable []string
+ selectFn := func(available []string) ([]string, error) {
+ receivedAvailable = available
+ // Only select the installed agent (simulate user not checking the new one)
+ return []string{string(agent.AgentNameClaudeCode)}, nil
+ }
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() error = %v", err)
+ }
+
+ // Should have prompted (re-run always prompts)
+ if len(receivedAvailable) == 0 {
+ t.Fatal("Expected interactive prompt on re-run")
+ }
+
+ // Newly detected agent should be available as an option
+ if len(receivedAvailable) < 2 {
+ t.Errorf("Expected at least 2 available agents (detected agent should be an option), got %d", len(receivedAvailable))
+ }
+
+ // Only the installed agent should be returned (user didn't select the new one)
+ if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
+ t.Errorf("Expected only [claude-code], got %v", agents)
+ }
+}
+
+func TestDetectOrSelectAgent_ReRun_EmptySelection_ReturnsError(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // Install Claude Code hooks (re-run scenario)
+ writeClaudeHooksFixture(t)
+
+ selectFn := func(_ []string) ([]string, error) {
+ return []string{}, nil // user deselected everything
+ }
+
+ var buf bytes.Buffer
+ _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
+ if err == nil {
+ t.Fatal("Expected error when no agents selected on re-run")
+ }
+ if !strings.Contains(err.Error(), "no agents selected") {
+ t.Errorf("Expected 'no agents selected' error, got: %v", err)
+ }
+}
+
+// Tests for configure --checkpoint-remote
+
+func TestConfigureCmd_CheckpointRemote_UpdatesProjectSettings(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--checkpoint-remote", "github:ashtom/zeugs-checkpoints"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --checkpoint-remote failed: %v", err)
+ }
+
+ if !strings.Contains(stdout.String(), "Settings updated") {
+ t.Errorf("expected 'Settings updated' output, got: %s", stdout.String())
+ }
+
+ // Verify the setting was written to settings.json
+ s, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ remote := s.GetCheckpointRemote()
+ if remote == nil {
+ t.Fatal("expected checkpoint_remote to be set")
+ return
+ }
+ if remote.Provider != "github" || remote.Repo != "ashtom/zeugs-checkpoints" {
+ t.Errorf("unexpected checkpoint_remote: %+v", remote)
+ }
+}
+
+func TestConfigureCmd_CheckpointRemote_WritesToLocalFile(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--local", "--checkpoint-remote", "github:org/repo"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --local --checkpoint-remote failed: %v", err)
+ }
+
+ if !strings.Contains(stdout.String(), "settings.local.json") {
+ t.Errorf("expected output to reference settings.local.json, got: %s", stdout.String())
+ }
+
+ // Verify the setting was written to settings.local.json, not settings.json
+ localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
+ if err != nil {
+ t.Fatalf("failed to load local settings: %v", err)
+ }
+ remote := localS.GetCheckpointRemote()
+ if remote == nil {
+ t.Fatal("expected checkpoint_remote in local settings")
+ }
+
+ // Project settings should be unchanged
+ projectS, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load project settings: %v", err)
+ }
+ if projectS.GetCheckpointRemote() != nil {
+ t.Error("checkpoint_remote should not leak into project settings")
+ }
+}
+
+func TestConfigureCmd_CheckpointRemote_LocalOnlyRepo(t *testing.T) {
+ setupTestRepo(t)
+ // Only local settings exist — no settings.json
+ writeLocalSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--checkpoint-remote", "github:org/repo"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --checkpoint-remote on local-only repo failed: %v", err)
+ }
+
+ // Should NOT create settings.json
+ if _, err := os.Stat(TraceSettingsFile); err == nil {
+ t.Error("settings.json should not be created in a local-only repo")
+ }
+
+ // Should write to settings.local.json
+ localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
+ if err != nil {
+ t.Fatalf("failed to load local settings: %v", err)
+ }
+ if localS.GetCheckpointRemote() == nil {
+ t.Error("expected checkpoint_remote in local settings")
+ }
+}
+
+func TestConfigureCmd_CheckpointRemote_InvalidFormat(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--checkpoint-remote", "invalid-format"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error for invalid --checkpoint-remote format")
+ }
+}
+
+func TestConfigureCmd_CheckpointRemote_DoesNotLeakMergedSettings(t *testing.T) {
+ setupTestRepo(t)
+ // Project has enabled=true, local has log_level override
+ writeSettings(t, testSettingsEnabled)
+ writeLocalSettings(t, `{"log_level": "debug"}`)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--project", "--checkpoint-remote", "github:org/repo"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --project --checkpoint-remote failed: %v", err)
+ }
+
+ // Project settings should NOT contain log_level from local
+ data, err := os.ReadFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to read settings: %v", err)
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ t.Fatalf("failed to parse settings: %v", err)
+ }
+ if _, exists := raw["log_level"]; exists {
+ t.Error("log_level from local settings leaked into project settings")
+ }
+}
+
+func stubCLIAvailable(t *testing.T) {
+ t.Helper()
+ orig := isSummaryCLIAvailable
+ isSummaryCLIAvailable = func(types.AgentName) bool { return true }
+ t.Cleanup(func() { isSummaryCLIAvailable = orig })
+}
+
+func TestConfigureCmd_SummarizeProvider_UpdatesProjectSettings(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ stubCLIAvailable(t)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-provider", "codex", "--summarize-model", "gpt-5"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --summarize-provider failed: %v", err)
+ }
+
+ if !strings.Contains(stdout.String(), "Settings updated") {
+ t.Errorf("expected 'Settings updated' output, got: %s", stdout.String())
+ }
+
+ s, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if s.SummaryGeneration == nil {
+ t.Fatal("expected summary_generation to be set")
+ }
+ if s.SummaryGeneration.Provider != "codex" {
+ t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex")
+ }
+ if s.SummaryGeneration.Model != "gpt-5" {
+ t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "gpt-5")
+ }
+}
+
+func TestConfigureCmd_SummarizeProvider_WritesToLocalFile(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ stubCLIAvailable(t)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--local", "--summarize-provider", "claude-code", "--summarize-model", "sonnet"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --local --summarize-provider failed: %v", err)
+ }
+
+ if !strings.Contains(stdout.String(), "settings.local.json") {
+ t.Errorf("expected output to reference settings.local.json, got: %s", stdout.String())
+ }
+
+ localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
+ if err != nil {
+ t.Fatalf("failed to load local settings: %v", err)
+ }
+ if localS.SummaryGeneration == nil {
+ t.Fatal("expected local summary_generation to be set")
+ }
+ if localS.SummaryGeneration.Provider != "claude-code" {
+ t.Fatalf("local summary provider = %q, want %q", localS.SummaryGeneration.Provider, "claude-code")
+ }
+
+ projectS, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load project settings: %v", err)
+ }
+ if projectS.SummaryGeneration != nil {
+ t.Fatal("summary_generation should not leak into project settings")
+ }
+}
diff --git a/cli/setup_4_test.go b/cli/setup_4_test.go
new file mode 100644
index 0000000..24aac6d
--- /dev/null
+++ b/cli/setup_4_test.go
@@ -0,0 +1,602 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ _ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+)
+
+func TestConfigureCmd_SummarizeProvider_ExternalEnablesExternalAgents(t *testing.T) {
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ const provider = "external-summary-config"
+ externalDir := t.TempDir()
+ writeExternalSummaryAgentBinary(t, externalDir, provider)
+ t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-provider", provider})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --summarize-provider external failed: %v", err)
+ }
+
+ s, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if s.SummaryGeneration == nil {
+ t.Fatal("expected summary_generation to be set")
+ }
+ if s.SummaryGeneration.Provider != provider {
+ t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, provider)
+ }
+ if !s.ExternalAgents {
+ t.Fatal("external summary provider should enable external_agents")
+ }
+ if !strings.Contains(stdout.String(), externalAgentsAutoEnabledNotice) {
+ t.Fatalf("expected notice surfacing the external_agents flip, got stdout:\n%s", stdout.String())
+ }
+}
+
+func TestConfigureCmd_SummarizeProvider_ExternalAlreadyEnabled_NoNotice(t *testing.T) {
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not available")
+ }
+
+ setupTestRepo(t)
+ writeSettings(t, `{"enabled": true, "external_agents": true}`)
+
+ const provider = "external-summary-already-on"
+ externalDir := t.TempDir()
+ writeExternalSummaryAgentBinary(t, externalDir, provider)
+ t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-provider", provider})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --summarize-provider external failed: %v", err)
+ }
+
+ if strings.Contains(stdout.String(), externalAgentsAutoEnabledNotice) {
+ t.Fatalf("notice should not fire when external_agents was already enabled, got stdout:\n%s", stdout.String())
+ }
+}
+
+func TestConfigureCmd_SummarizeProvider_InvalidProvider(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-provider", "opencode"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error for unsupported summary provider")
+ }
+}
+
+func TestConfigureCmd_SummarizeProvider_SwitchClearsStaleModel(t *testing.T) {
+ stubCLIAvailable(t)
+ setupTestRepo(t)
+ writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code", "model": "sonnet"}}`)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-provider", "codex"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --summarize-provider codex failed: %v", err)
+ }
+
+ s, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if s.SummaryGeneration == nil {
+ t.Fatal("expected summary_generation to be set")
+ }
+ if s.SummaryGeneration.Provider != "codex" {
+ t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex")
+ }
+ if s.SummaryGeneration.Model != "" {
+ t.Fatalf("summary model = %q, want empty after provider switch", s.SummaryGeneration.Model)
+ }
+}
+
+func TestConfigureCmd_SummarizeModel_RequiresProvider(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-model", "sonnet"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error for summarize-model without provider")
+ }
+}
+
+func TestConfigureCmd_SummarizeModel_LocalInheritsProviderFromProject(t *testing.T) {
+ setupTestRepo(t)
+ stubCLIAvailable(t)
+ // Project settings define the provider; local override only sets the model.
+ writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code"}}`)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--local", "--summarize-model", "sonnet"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --local --summarize-model failed: %v", err)
+ }
+
+ localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
+ if err != nil {
+ t.Fatalf("failed to load local settings: %v", err)
+ }
+ if localS.SummaryGeneration == nil {
+ t.Fatal("expected local summary_generation to be set")
+ }
+ if localS.SummaryGeneration.Model != "sonnet" {
+ t.Fatalf("local summary model = %q, want %q", localS.SummaryGeneration.Model, "sonnet")
+ }
+
+ // Project settings must not be modified.
+ projectS, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load project settings: %v", err)
+ }
+ if projectS.SummaryGeneration.Model != "" {
+ t.Fatalf("project model = %q, should remain empty", projectS.SummaryGeneration.Model)
+ }
+}
+
+func TestConfigureCmd_SummarizeModel_UsesExistingProvider(t *testing.T) {
+ setupTestRepo(t)
+ stubCLIAvailable(t)
+ writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code"}}`)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--summarize-model", "sonnet"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --summarize-model failed: %v", err)
+ }
+
+ s, err := settings.LoadFromFile(TraceSettingsFile)
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if s.SummaryGeneration == nil {
+ t.Fatal("expected summary_generation to be set")
+ }
+ if s.SummaryGeneration.Provider != "claude-code" {
+ t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "claude-code")
+ }
+ if s.SummaryGeneration.Model != "sonnet" {
+ t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "sonnet")
+ }
+}
+
+func TestSelectAllAgents_ReturnsAll(t *testing.T) {
+ t.Parallel()
+ available := []string{"claude-code", "gemini-cli", "opencode"}
+ selected, err := selectAllAgents(available)
+ if err != nil {
+ t.Fatalf("selectAllAgents() error = %v", err)
+ }
+ if !slices.Equal(selected, available) {
+ t.Errorf("selectAllAgents() = %v, want %v", selected, available)
+ }
+}
+
+func TestSelectAllAgents_EmptyReturnsError(t *testing.T) {
+ t.Parallel()
+ _, err := selectAllAgents(nil)
+ if err == nil {
+ t.Fatal("expected error for empty input")
+ }
+}
+
+func TestDetectOrSelectAgent_YesSelectsAll(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ var buf bytes.Buffer
+ agents, err := detectOrSelectAgent(context.Background(), &buf, selectAllAgents)
+ if err != nil {
+ t.Fatalf("detectOrSelectAgent() with selectAllAgents error = %v", err)
+ }
+
+ // Should return at least 2 agents (claude-code + gemini-cli are registered in test imports)
+ if len(agents) < 2 {
+ t.Errorf("expected at least 2 agents with selectAllAgents, got %d", len(agents))
+ }
+
+ output := buf.String()
+ if !strings.Contains(output, "Selected agents:") {
+ t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
+ }
+}
+
+func TestManageAgents_YesWorksNonInteractive(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+
+ // Install claude-code hooks so there's something installed
+ writeClaudeHooksFixture(t)
+
+ // Use a selectFn that only picks built-in agents to avoid failures
+ // from stale external agent binaries registered by other tests.
+ selectBuiltIn := func(available []string) ([]string, error) {
+ var selected []string
+ for _, name := range available {
+ ag, err := agent.Get(types.AgentName(name))
+ if err != nil {
+ continue
+ }
+ if isBuiltInAgent(ag) {
+ selected = append(selected, name)
+ }
+ }
+ if len(selected) == 0 {
+ return nil, errors.New("no built-in agents available")
+ }
+ return selected, nil
+ }
+
+ var buf bytes.Buffer
+ err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectBuiltIn)
+ if err != nil {
+ t.Fatalf("runManageAgents() with selectFn in non-interactive mode error = %v", err)
+ }
+
+ output := buf.String()
+ // Should NOT print the non-interactive bail-out message
+ if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
+ t.Error("selectFn should bypass the interactivity check, but got non-interactive message")
+ }
+}
+
+func TestEnableYes_TelemetryRespectsOptOut(t *testing.T) {
+ // Cannot use t.Parallel() because subtests use t.Setenv
+
+ t.Run("yes with telemetry=false", func(t *testing.T) {
+ s := &TraceSettings{}
+ opts := EnableOptions{Yes: true, Telemetry: false}
+ if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ s.Telemetry = &f
+ } else if s.Telemetry == nil {
+ tr := true
+ s.Telemetry = &tr
+ }
+ if s.Telemetry == nil || *s.Telemetry != false {
+ t.Errorf("expected telemetry=false when --yes --telemetry=false, got %v", s.Telemetry)
+ }
+ })
+
+ t.Run("yes with TRACE_TELEMETRY_OPTOUT", func(t *testing.T) {
+ t.Setenv("TRACE_TELEMETRY_OPTOUT", "1")
+ s := &TraceSettings{}
+ opts := EnableOptions{Yes: true, Telemetry: true}
+ if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ s.Telemetry = &f
+ } else if s.Telemetry == nil {
+ tr := true
+ s.Telemetry = &tr
+ }
+ if s.Telemetry == nil || *s.Telemetry != false {
+ t.Errorf("expected telemetry=false with TRACE_TELEMETRY_OPTOUT, got %v", s.Telemetry)
+ }
+ })
+
+ t.Run("yes defaults to telemetry enabled", func(t *testing.T) {
+ s := &TraceSettings{}
+ opts := EnableOptions{Yes: true, Telemetry: true}
+ if !opts.Telemetry {
+ f := false
+ s.Telemetry = &f
+ } else if s.Telemetry == nil {
+ tr := true
+ s.Telemetry = &tr
+ }
+ if s.Telemetry == nil || *s.Telemetry != true {
+ t.Errorf("expected telemetry=true with --yes (default), got %v", s.Telemetry)
+ }
+ })
+
+ t.Run("yes preserves existing telemetry setting", func(t *testing.T) {
+ existing := false
+ s := &TraceSettings{Telemetry: &existing}
+ opts := EnableOptions{Yes: true, Telemetry: true}
+ if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
+ f := false
+ s.Telemetry = &f
+ } else if s.Telemetry == nil {
+ tr := true
+ s.Telemetry = &tr
+ }
+ if *s.Telemetry != false {
+ t.Errorf("expected existing telemetry=false to be preserved, got %v", *s.Telemetry)
+ }
+ })
+}
+
+func TestEnableCmd_YesFreshRepo_SkipsPromptsAndEnables(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ testutil.WriteFile(t, ".", "f.txt", "init")
+ testutil.GitAdd(t, ".", "f.txt")
+ testutil.GitCommit(t, ".", "init")
+
+ // Use --yes with --agent to test the realistic CI scenario.
+ // The --yes flag skips telemetry/Vercel prompts while --agent selects a specific agent.
+ // The pure --yes-selects-all-agents path is covered by TestDetectOrSelectAgent_YesSelectsAll.
+ cmd := newEnableCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--yes", "--agent", "claude-code"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable --yes --agent claude-code error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Ready.") {
+ t.Errorf("expected 'Ready.' in output, got: %s", output)
+ }
+
+ // Verify settings were saved with telemetry enabled (--yes default)
+ s, err := LoadTraceSettings(context.Background())
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if !s.Enabled {
+ t.Error("expected enabled=true")
+ }
+}
+
+func TestEnableCmd_YesWithAgent_AgentTakesPrecedence(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ testutil.WriteFile(t, ".", "f.txt", "init")
+ testutil.GitAdd(t, ".", "f.txt")
+ testutil.GitCommit(t, ".", "init")
+
+ cmd := newEnableCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--yes", "--agent", "claude-code"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable --yes --agent claude-code error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
+ }
+
+ output := stdout.String()
+ // --agent takes precedence — should show single-agent non-interactive output
+ if !strings.Contains(output, "Agent: Claude Code") {
+ t.Errorf("expected 'Agent: Claude Code' in output, got: %s", output)
+ }
+ // Should NOT have shown multi-select output
+ if strings.Contains(output, "Selected agents:") {
+ t.Errorf("--agent should bypass multi-select, but got 'Selected agents:' in: %s", output)
+ }
+}
+
+func TestEnableCmd_YesOnConfiguredRepo_ManagesAgents(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ cmd := newEnableCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--yes"})
+
+ // May partially fail due to stale external agents in global registry,
+ // but the key behavior is that it doesn't bail out with the non-interactive message.
+ _ = cmd.Execute() //nolint:errcheck // partial failure from stale test agents is expected
+
+ output := stdout.String()
+ // Should NOT bail out with non-interactive message
+ if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
+ t.Error("--yes should bypass non-interactive check, but got bail-out message")
+ }
+}
+
+func TestEnableCmd_YesWithTelemetryFalse(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
+ setupTestRepo(t)
+ testutil.WriteFile(t, ".", "f.txt", "init")
+ testutil.GitAdd(t, ".", "f.txt")
+ testutil.GitCommit(t, ".", "init")
+
+ cmd := newEnableCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--yes", "--agent", "claude-code", "--telemetry=false"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("enable --yes --telemetry=false error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
+ }
+
+ // Verify telemetry was disabled despite --yes
+ s, err := LoadTraceSettings(context.Background())
+ if err != nil {
+ t.Fatalf("failed to load settings: %v", err)
+ }
+ if s.Telemetry == nil || *s.Telemetry != false {
+ t.Errorf("expected telemetry=false when --yes --telemetry=false, got %v", s.Telemetry)
+ }
+}
+
+func TestConfigureCmd_BarePrintsHelpHint(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ cmd := newSetupCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "trace agent") {
+ t.Errorf("expected hint about 'trace agent' in help output, got: %s", output)
+ }
+ // Bare configure must not run the agent picker.
+ if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
+ t.Errorf("bare configure should not invoke agent picker, got: %s", output)
+ }
+}
+
+func TestConfigureCmd_AgentFlagRemoved(t *testing.T) {
+ t.Parallel()
+ cmd := newSetupCmd()
+ if cmd.Flags().Lookup("agent") != nil {
+ t.Error("'configure' must not expose --agent (use 'trace agent add')")
+ }
+ if cmd.Flags().Lookup("remove") != nil {
+ t.Error("'configure' must not expose --remove (use 'trace agent remove')")
+ }
+ if cmd.Flags().Lookup("yes") != nil {
+ t.Error("'configure' must not expose --yes (lives on 'trace enable')")
+ }
+}
+
+func TestConfigureCmd_TelemetryFlag_PersistsSetting(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--telemetry=false"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --telemetry=false error = %v", err)
+ }
+
+ s, err := LoadTraceSettings(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if s.Telemetry == nil || *s.Telemetry != false {
+ t.Errorf("expected telemetry=false, got %v", s.Telemetry)
+ }
+}
+
+func TestConfigureCmd_AbsoluteGitHookPathFlag_PersistsAndReinstallsHook(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--absolute-git-hook-path"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --absolute-git-hook-path error = %v", err)
+ }
+
+ s, err := LoadTraceSettings(context.Background())
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if !s.AbsoluteGitHookPath {
+ t.Error("expected absolute_git_hook_path=true after configure --absolute-git-hook-path")
+ }
+ if !strings.Contains(stdout.String(), "Reinstalled git hook") {
+ t.Errorf("expected hook reinstall message, got: %s", stdout.String())
+ }
+}
+
+func TestConfigureCmd_TelemetryAlone_DoesNotReinstallHook(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ cmd := newSetupCmd()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--telemetry=false"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("configure --telemetry=false error = %v", err)
+ }
+
+ if strings.Contains(stdout.String(), "Reinstalled git hook") {
+ t.Errorf("--telemetry alone should not trigger hook reinstall, got: %s", stdout.String())
+ }
+}
+
+func TestConfigureCmd_FreshRepo_PointsAtEnable(t *testing.T) {
+ // Cannot use t.Parallel() because we use t.Chdir
+ setupTestRepo(t)
+ // No settings written — fresh repo.
+
+ cmd := newSetupCmd()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--telemetry=false"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected configure on fresh repo to fail")
+ }
+ if !strings.Contains(stderr.String(), "trace enable") {
+ t.Errorf("expected hint pointing at 'trace enable', got stderr: %s", stderr.String())
+ }
+}
diff --git a/cli/setup_github_2_test.go b/cli/setup_github_2_test.go
new file mode 100644
index 0000000..4092c19
--- /dev/null
+++ b/cli/setup_github_2_test.go
@@ -0,0 +1,381 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEnsureGitIdentity_NonInteractiveNoGh_Errors(t *testing.T) {
+ r := newFakeRunner()
+ r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set"))
+ r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set"))
+ r.set("gh", []string{"--version"}, "", errors.New("not found"))
+
+ err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir())
+ if err == nil {
+ t.Fatal("expected error when identity missing and gh unavailable")
+ }
+ if !strings.Contains(err.Error(), "git config --global user.name") {
+ t.Fatalf("expected guidance to set git config, got %v", err)
+ }
+}
+
+func TestGhUserIdentity_NameFallsBackToLogin(t *testing.T) {
+ t.Parallel()
+ r := newFakeRunner()
+ r.set("gh", []string{"api", "user"}, `{"id":7,"login":"dev","name":"","email":"dev@example.com"}`, nil)
+ name, email, err := ghUserIdentity(context.Background(), r)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if name != "dev" {
+ t.Fatalf("name = %q", name)
+ }
+ if email != "dev@example.com" {
+ t.Fatalf("email = %q", email)
+ }
+}
+
+// TestBootstrap_FreshMachine_RealGit is an integration-style test that runs
+// real git via execRunner on a temp dir isolated from the user's global git
+// config. Regression guard for the issue where bootstrap commits failed
+// without a configured identity or because of commit.gpgsign=true.
+func TestBootstrap_FreshMachine_RealGit(t *testing.T) {
+ // Isolate from any global git config: point HOME + GIT_CONFIG_* at
+ // empty/missing locations, and force a broken GPG signing config that
+ // would fail any commit if we did not pass -c commit.gpgsign=false.
+ emptyHome := t.TempDir()
+ t.Setenv("HOME", emptyHome)
+ t.Setenv("XDG_CONFIG_HOME", "")
+ // A global config that demands signing with a non-existent program. If
+ // our bootstrap did not override gpgsign for its commit, git would
+ // error out here.
+ globalCfg := filepath.Join(emptyHome, ".gitconfig")
+ globalContent := "[user]\n\tname = Fresh User\n\temail = fresh@example.com\n[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = /does/not/exist\n"
+ if err := writeTempFile(globalCfg, globalContent); err != nil {
+ t.Fatalf("write global gitconfig: %v", err)
+ }
+ t.Setenv("GIT_CONFIG_GLOBAL", globalCfg)
+ // Ensure no system config interferes.
+ t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")
+
+ projectDir := t.TempDir()
+ restoreCwd(t, projectDir)
+ // Create a file to commit.
+ if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hello\n"); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+
+ opts := GitHubBootstrapOptions{
+ InitRepo: true,
+ NoGitHub: true,
+ InitialCommitMessage: "Initial",
+ }
+ err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, execRunner{})
+ if err != nil {
+ t.Fatalf("bootstrap failed: %v", err)
+ }
+
+ // Verify a commit actually landed on HEAD.
+ out, err := execRunner{}.RunInDir(context.Background(), projectDir, "git", "log", "--oneline")
+ if err != nil {
+ t.Fatalf("git log failed: %v", err)
+ }
+ if !strings.Contains(out, "Initial") {
+ t.Fatalf("expected 'Initial' commit in log, got: %q", out)
+ }
+}
+
+func writeTempFile(path, content string) error {
+ return os.WriteFile(path, []byte(content), 0o600)
+}
+
+// ghFailingRunner wraps another bootstrapRunner and forces all `gh`
+// invocations to fail, while letting real `git` calls through. This
+// lets tests deterministically exercise the "gh unavailable" path
+// regardless of whether `gh` is installed/authenticated on the host.
+type ghFailingRunner struct {
+ inner bootstrapRunner
+}
+
+func (r ghFailingRunner) Run(ctx context.Context, name string, args ...string) (string, error) {
+ if name == "gh" {
+ return "", errors.New("gh not available (test)")
+ }
+ return r.inner.Run(ctx, name, args...)
+}
+
+func (r ghFailingRunner) RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
+ if name == "gh" {
+ return "", errors.New("gh not available (test)")
+ }
+ return r.inner.RunInDir(ctx, dir, name, args...)
+}
+
+// TestBootstrap_FreshMachine_NoIdentity_RealGit verifies that a fresh
+// machine without any git identity configured fails cleanly in
+// non-interactive mode with a helpful error message, instead of letting
+// `git commit` fail with a confusing "please tell me who you are" stderr.
+//
+// Uses a gh-failing runner wrapper rather than PATH manipulation so the
+// test isn't sensitive to whether `gh` + GH_TOKEN/GITHUB_TOKEN are set
+// on the host.
+func TestBootstrap_FreshMachine_NoIdentity_RealGit(t *testing.T) {
+ emptyHome := t.TempDir()
+ t.Setenv("HOME", emptyHome)
+ t.Setenv("XDG_CONFIG_HOME", "")
+ // Empty global config: no user.name/user.email.
+ globalCfg := filepath.Join(emptyHome, ".gitconfig")
+ if err := writeTempFile(globalCfg, ""); err != nil {
+ t.Fatalf("write global gitconfig: %v", err)
+ }
+ t.Setenv("GIT_CONFIG_GLOBAL", globalCfg)
+ t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")
+ // Belt-and-suspenders: unset any GitHub tokens so a wrapper bypass
+ // would still not find credentials.
+ t.Setenv("GH_TOKEN", "")
+ t.Setenv("GITHUB_TOKEN", "")
+
+ projectDir := t.TempDir()
+ restoreCwd(t, projectDir)
+ if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hi\n"); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+
+ opts := GitHubBootstrapOptions{
+ InitRepo: true,
+ NoGitHub: true,
+ InitialCommitMessage: "x",
+ }
+ runner := ghFailingRunner{inner: execRunner{}}
+ err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, runner)
+ if err == nil {
+ t.Fatal("expected error when identity missing and gh unavailable")
+ }
+ if !strings.Contains(err.Error(), "git config --global user.name") {
+ t.Fatalf("expected guidance to set git config, got: %v", err)
+ }
+}
+
+// TestErrSentinels_DistinctPrePostInit documents the contract that the two
+// error sentinels signal: errBootstrapDeclined before `git init`,
+// errBootstrapInterrupted after. setup.go relies on this to show the
+// right user-facing message.
+func TestErrSentinels_DistinctPrePostInit(t *testing.T) {
+ t.Parallel()
+ if errors.Is(errBootstrapDeclined, errBootstrapInterrupted) {
+ t.Fatal("errBootstrapDeclined and errBootstrapInterrupted must not match as the same sentinel")
+ }
+}
+
+func TestEnableCmd_InitCommitMessageFlagsMutuallyExclusive(t *testing.T) {
+ setupTestRepo(t)
+
+ cmd := newEnableCmd()
+ var stderr bytes.Buffer
+ cmd.SetErr(&stderr)
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--initial-commit-message", "foo", "--skip-initial-commit"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error when both --initial-commit-message and --skip-initial-commit are set")
+ }
+ if !strings.Contains(err.Error(), "initial-commit-message") || !strings.Contains(err.Error(), "skip-initial-commit") {
+ t.Fatalf("expected error to mention both flags, got: %v", err)
+ }
+}
+
+func TestEnableCmd_InitRepoFlagsMutuallyExclusive(t *testing.T) {
+ setupTestRepo(t)
+
+ cmd := newEnableCmd()
+ var stderr bytes.Buffer
+ cmd.SetErr(&stderr)
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--init-repo", "--no-init-repo"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error when both --init-repo and --no-init-repo are set")
+ }
+ if !strings.Contains(err.Error(), "init-repo") || !strings.Contains(err.Error(), "no-init-repo") {
+ t.Fatalf("expected error to mention both flags, got: %v", err)
+ }
+}
+
+// restoreCwd chdirs into dir for the duration of the test.
+func restoreCwd(t *testing.T, dir string) {
+ t.Helper()
+ // macOS resolves /tmp → /private/tmp; canonicalize for safety.
+ canon, err := filepath.EvalSymlinks(dir)
+ if err != nil {
+ canon = dir
+ }
+ t.Chdir(canon)
+}
+
+func TestRunGitHubBootstrap_YesAcceptsAllDefaults(t *testing.T) {
+ // --yes should init repo, create GitHub repo under user's account (private),
+ // and use default commit message — without any interactive prompts.
+ dir := t.TempDir()
+ restoreCwd(t, dir)
+
+ r := newFakeRunner()
+ r.setIdentityConfigured()
+ r.set("gh", []string{"--version"}, "gh 2.81.0", nil)
+ r.set("gh", []string{"auth", "status"}, "Logged in", nil)
+ r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil)
+ r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "myorg\n", nil)
+ r.set("git", []string{"init"}, "", nil)
+ r.set("git", []string{"add", "-A"}, "", nil)
+ r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
+ r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"}, "", nil)
+
+ // Expect repo created under the user's account (not org), private
+ repoName := filepath.Base(dir)
+ fullName := "myuser/" + repoName
+ r.set("gh", []string{
+ "repo", "create", fullName,
+ "--private",
+ "--source=.",
+ "--remote=origin",
+ }, "", nil)
+ r.set("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"}, "", nil)
+
+ opts := GitHubBootstrapOptions{Yes: true}
+ var stdout bytes.Buffer
+ err := runGitHubBootstrapWith(context.Background(), &stdout, io.Discard, opts, r)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Should have used user's account, not org
+ output := stdout.String()
+ if !strings.Contains(output, "Using GitHub owner: myuser") {
+ t.Errorf("expected owner to be user's account, got: %s", output)
+ }
+ // Should have committed with default message
+ if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"})) {
+ t.Error("expected commit with default 'Initial commit' message")
+ }
+ // Should have created the repo
+ if !r.hasCall(func(c fakeCall) bool {
+ return c.name == "gh" && len(c.args) > 3 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate
+ }) {
+ t.Error("expected gh repo create call")
+ }
+}
+
+func TestRunGitHubBootstrap_YesRepoExistsNoTTY_Fails(t *testing.T) {
+ // When --yes is set, the repo name is taken, and there's no TTY,
+ // we should get a clear error instead of a silent gh failure.
+ dir := t.TempDir()
+ restoreCwd(t, dir)
+
+ r := newFakeRunner()
+ r.setIdentityConfigured()
+ r.set("gh", []string{"--version"}, "gh 2.81.0", nil)
+ r.set("gh", []string{"auth", "status"}, "Logged in", nil)
+ r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil)
+ r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil)
+ r.set("git", []string{"init"}, "", nil)
+
+ // The suggested repo name already exists.
+ repoName := filepath.Base(dir)
+ r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil)
+
+ opts := GitHubBootstrapOptions{Yes: true}
+ err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r)
+ if err == nil {
+ t.Fatal("expected error when repo name exists and no TTY")
+ }
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Errorf("expected 'already exists' in error, got: %v", err)
+ }
+}
+
+func TestResolveRepoName_YesRepoExistsWithTTY_FallsBackToPrompt(t *testing.T) {
+ // When --yes is set, the name is taken, and a TTY is available,
+ // resolveRepoName should print a conflict message and fall through
+ // to the interactive prompt. We verify the conflict message was
+ // printed (proving the fallback path was taken).
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ // Force accessible (text-based) mode so the huh form reads from
+ // os.Stdin instead of trying to open /dev/tty via bubbletea.
+ // Pipe a unique name so the form completes instead of blocking.
+ t.Setenv("ACCESSIBLE", "1")
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { pr.Close() })
+ go func() {
+ // The form reads one line; provide a unique name so it exits the loop.
+ pw.WriteString("unique-test-repo\n") //nolint:errcheck // test helper
+ pw.Close()
+ }()
+ oldStdin := os.Stdin
+ os.Stdin = pr
+ t.Cleanup(func() { os.Stdin = oldStdin })
+
+ dir := t.TempDir()
+ restoreCwd(t, dir)
+
+ r := newFakeRunner()
+ repoName := filepath.Base(dir)
+ // The suggested name exists.
+ r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil)
+ // The unique name typed at the prompt does not exist (fakeRunner returns
+ // an error for unknown calls, which ghRepoExists treats as "proceed").
+
+ var stdout bytes.Buffer
+ opts := GitHubBootstrapOptions{Yes: true}
+ name, err := resolveRepoName(context.Background(), &stdout, io.Discard, r, "myuser", dir, opts)
+
+ output := stdout.String()
+ if !strings.Contains(output, "already exists on GitHub") {
+ t.Errorf("expected conflict message in output, got: %s", output)
+ }
+ // The form should complete with the unique name (fakeRunner can't verify
+ // the name, so resolveRepoName proceeds with a warning).
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if name != "unique-test-repo" {
+ t.Errorf("expected name %q, got %q", "unique-test-repo", name)
+ }
+}
+
+func TestRunGitHubBootstrap_YesWithNoGitHub(t *testing.T) {
+ // --yes combined with --no-github should skip GitHub but still init + commit.
+ dir := t.TempDir()
+ restoreCwd(t, dir)
+
+ r := newFakeRunner()
+ r.setIdentityConfigured()
+ r.set("git", []string{"init"}, "", nil)
+ r.set("git", []string{"add", "-A"}, "", nil)
+ r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
+ r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"}, "", nil)
+
+ opts := GitHubBootstrapOptions{Yes: true, NoGitHub: true}
+ err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Should NOT have called gh at all
+ if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) {
+ t.Error("expected no gh calls with --no-github")
+ }
+ // Should have committed
+ if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"})) {
+ t.Error("expected commit with default message")
+ }
+}
diff --git a/cli/setup_github_test.go b/cli/setup_github_test.go
index 2315306..39adb63 100644
--- a/cli/setup_github_test.go
+++ b/cli/setup_github_test.go
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
- "os"
"path/filepath"
"strings"
"sync"
@@ -812,372 +811,3 @@ func TestEnsureGitIdentity_PreservesExistingEmail(t *testing.T) {
t.Fatal("ensureGitIdentity should not write user.email when it's already set globally")
}
}
-
-func TestEnsureGitIdentity_NonInteractiveNoGh_Errors(t *testing.T) {
- r := newFakeRunner()
- r.set("git", []string{"config", "--get", "user.name"}, "", errors.New("not set"))
- r.set("git", []string{"config", "--get", "user.email"}, "", errors.New("not set"))
- r.set("gh", []string{"--version"}, "", errors.New("not found"))
-
- err := ensureGitIdentity(context.Background(), io.Discard, io.Discard, r, t.TempDir())
- if err == nil {
- t.Fatal("expected error when identity missing and gh unavailable")
- }
- if !strings.Contains(err.Error(), "git config --global user.name") {
- t.Fatalf("expected guidance to set git config, got %v", err)
- }
-}
-
-func TestGhUserIdentity_NameFallsBackToLogin(t *testing.T) {
- t.Parallel()
- r := newFakeRunner()
- r.set("gh", []string{"api", "user"}, `{"id":7,"login":"dev","name":"","email":"dev@example.com"}`, nil)
- name, email, err := ghUserIdentity(context.Background(), r)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if name != "dev" {
- t.Fatalf("name = %q", name)
- }
- if email != "dev@example.com" {
- t.Fatalf("email = %q", email)
- }
-}
-
-// TestBootstrap_FreshMachine_RealGit is an integration-style test that runs
-// real git via execRunner on a temp dir isolated from the user's global git
-// config. Regression guard for the issue where bootstrap commits failed
-// without a configured identity or because of commit.gpgsign=true.
-func TestBootstrap_FreshMachine_RealGit(t *testing.T) {
- // Isolate from any global git config: point HOME + GIT_CONFIG_* at
- // empty/missing locations, and force a broken GPG signing config that
- // would fail any commit if we did not pass -c commit.gpgsign=false.
- emptyHome := t.TempDir()
- t.Setenv("HOME", emptyHome)
- t.Setenv("XDG_CONFIG_HOME", "")
- // A global config that demands signing with a non-existent program. If
- // our bootstrap did not override gpgsign for its commit, git would
- // error out here.
- globalCfg := filepath.Join(emptyHome, ".gitconfig")
- globalContent := "[user]\n\tname = Fresh User\n\temail = fresh@example.com\n[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = /does/not/exist\n"
- if err := writeTempFile(globalCfg, globalContent); err != nil {
- t.Fatalf("write global gitconfig: %v", err)
- }
- t.Setenv("GIT_CONFIG_GLOBAL", globalCfg)
- // Ensure no system config interferes.
- t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")
-
- projectDir := t.TempDir()
- restoreCwd(t, projectDir)
- // Create a file to commit.
- if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hello\n"); err != nil {
- t.Fatalf("write file: %v", err)
- }
-
- opts := GitHubBootstrapOptions{
- InitRepo: true,
- NoGitHub: true,
- InitialCommitMessage: "Initial",
- }
- err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, execRunner{})
- if err != nil {
- t.Fatalf("bootstrap failed: %v", err)
- }
-
- // Verify a commit actually landed on HEAD.
- out, err := execRunner{}.RunInDir(context.Background(), projectDir, "git", "log", "--oneline")
- if err != nil {
- t.Fatalf("git log failed: %v", err)
- }
- if !strings.Contains(out, "Initial") {
- t.Fatalf("expected 'Initial' commit in log, got: %q", out)
- }
-}
-
-func writeTempFile(path, content string) error {
- return os.WriteFile(path, []byte(content), 0o600)
-}
-
-// ghFailingRunner wraps another bootstrapRunner and forces all `gh`
-// invocations to fail, while letting real `git` calls through. This
-// lets tests deterministically exercise the "gh unavailable" path
-// regardless of whether `gh` is installed/authenticated on the host.
-type ghFailingRunner struct {
- inner bootstrapRunner
-}
-
-func (r ghFailingRunner) Run(ctx context.Context, name string, args ...string) (string, error) {
- if name == "gh" {
- return "", errors.New("gh not available (test)")
- }
- return r.inner.Run(ctx, name, args...)
-}
-
-func (r ghFailingRunner) RunInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
- if name == "gh" {
- return "", errors.New("gh not available (test)")
- }
- return r.inner.RunInDir(ctx, dir, name, args...)
-}
-
-// TestBootstrap_FreshMachine_NoIdentity_RealGit verifies that a fresh
-// machine without any git identity configured fails cleanly in
-// non-interactive mode with a helpful error message, instead of letting
-// `git commit` fail with a confusing "please tell me who you are" stderr.
-//
-// Uses a gh-failing runner wrapper rather than PATH manipulation so the
-// test isn't sensitive to whether `gh` + GH_TOKEN/GITHUB_TOKEN are set
-// on the host.
-func TestBootstrap_FreshMachine_NoIdentity_RealGit(t *testing.T) {
- emptyHome := t.TempDir()
- t.Setenv("HOME", emptyHome)
- t.Setenv("XDG_CONFIG_HOME", "")
- // Empty global config: no user.name/user.email.
- globalCfg := filepath.Join(emptyHome, ".gitconfig")
- if err := writeTempFile(globalCfg, ""); err != nil {
- t.Fatalf("write global gitconfig: %v", err)
- }
- t.Setenv("GIT_CONFIG_GLOBAL", globalCfg)
- t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")
- // Belt-and-suspenders: unset any GitHub tokens so a wrapper bypass
- // would still not find credentials.
- t.Setenv("GH_TOKEN", "")
- t.Setenv("GITHUB_TOKEN", "")
-
- projectDir := t.TempDir()
- restoreCwd(t, projectDir)
- if err := writeTempFile(filepath.Join(projectDir, "README.md"), "hi\n"); err != nil {
- t.Fatalf("write file: %v", err)
- }
-
- opts := GitHubBootstrapOptions{
- InitRepo: true,
- NoGitHub: true,
- InitialCommitMessage: "x",
- }
- runner := ghFailingRunner{inner: execRunner{}}
- err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, runner)
- if err == nil {
- t.Fatal("expected error when identity missing and gh unavailable")
- }
- if !strings.Contains(err.Error(), "git config --global user.name") {
- t.Fatalf("expected guidance to set git config, got: %v", err)
- }
-}
-
-// TestErrSentinels_DistinctPrePostInit documents the contract that the two
-// error sentinels signal: errBootstrapDeclined before `git init`,
-// errBootstrapInterrupted after. setup.go relies on this to show the
-// right user-facing message.
-func TestErrSentinels_DistinctPrePostInit(t *testing.T) {
- t.Parallel()
- if errors.Is(errBootstrapDeclined, errBootstrapInterrupted) {
- t.Fatal("errBootstrapDeclined and errBootstrapInterrupted must not match as the same sentinel")
- }
-}
-
-func TestEnableCmd_InitCommitMessageFlagsMutuallyExclusive(t *testing.T) {
- setupTestRepo(t)
-
- cmd := newEnableCmd()
- var stderr bytes.Buffer
- cmd.SetErr(&stderr)
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetArgs([]string{"--initial-commit-message", "foo", "--skip-initial-commit"})
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error when both --initial-commit-message and --skip-initial-commit are set")
- }
- if !strings.Contains(err.Error(), "initial-commit-message") || !strings.Contains(err.Error(), "skip-initial-commit") {
- t.Fatalf("expected error to mention both flags, got: %v", err)
- }
-}
-
-func TestEnableCmd_InitRepoFlagsMutuallyExclusive(t *testing.T) {
- setupTestRepo(t)
-
- cmd := newEnableCmd()
- var stderr bytes.Buffer
- cmd.SetErr(&stderr)
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetArgs([]string{"--init-repo", "--no-init-repo"})
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error when both --init-repo and --no-init-repo are set")
- }
- if !strings.Contains(err.Error(), "init-repo") || !strings.Contains(err.Error(), "no-init-repo") {
- t.Fatalf("expected error to mention both flags, got: %v", err)
- }
-}
-
-// restoreCwd chdirs into dir for the duration of the test.
-func restoreCwd(t *testing.T, dir string) {
- t.Helper()
- // macOS resolves /tmp → /private/tmp; canonicalize for safety.
- canon, err := filepath.EvalSymlinks(dir)
- if err != nil {
- canon = dir
- }
- t.Chdir(canon)
-}
-
-func TestRunGitHubBootstrap_YesAcceptsAllDefaults(t *testing.T) {
- // --yes should init repo, create GitHub repo under user's account (private),
- // and use default commit message — without any interactive prompts.
- dir := t.TempDir()
- restoreCwd(t, dir)
-
- r := newFakeRunner()
- r.setIdentityConfigured()
- r.set("gh", []string{"--version"}, "gh 2.81.0", nil)
- r.set("gh", []string{"auth", "status"}, "Logged in", nil)
- r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil)
- r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "myorg\n", nil)
- r.set("git", []string{"init"}, "", nil)
- r.set("git", []string{"add", "-A"}, "", nil)
- r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
- r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"}, "", nil)
-
- // Expect repo created under the user's account (not org), private
- repoName := filepath.Base(dir)
- fullName := "myuser/" + repoName
- r.set("gh", []string{
- "repo", "create", fullName,
- "--private",
- "--source=.",
- "--remote=origin",
- }, "", nil)
- r.set("git", []string{"push", "-q", "--no-verify", "-u", "origin", "HEAD"}, "", nil)
-
- opts := GitHubBootstrapOptions{Yes: true}
- var stdout bytes.Buffer
- err := runGitHubBootstrapWith(context.Background(), &stdout, io.Discard, opts, r)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Should have used user's account, not org
- output := stdout.String()
- if !strings.Contains(output, "Using GitHub owner: myuser") {
- t.Errorf("expected owner to be user's account, got: %s", output)
- }
- // Should have committed with default message
- if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"})) {
- t.Error("expected commit with default 'Initial commit' message")
- }
- // Should have created the repo
- if !r.hasCall(func(c fakeCall) bool {
- return c.name == "gh" && len(c.args) > 3 && c.args[0] == ghSubcmdRepo && c.args[1] == ghActCreate
- }) {
- t.Error("expected gh repo create call")
- }
-}
-
-func TestRunGitHubBootstrap_YesRepoExistsNoTTY_Fails(t *testing.T) {
- // When --yes is set, the repo name is taken, and there's no TTY,
- // we should get a clear error instead of a silent gh failure.
- dir := t.TempDir()
- restoreCwd(t, dir)
-
- r := newFakeRunner()
- r.setIdentityConfigured()
- r.set("gh", []string{"--version"}, "gh 2.81.0", nil)
- r.set("gh", []string{"auth", "status"}, "Logged in", nil)
- r.set("gh", []string{"api", "user", "--jq", ".login"}, "myuser\n", nil)
- r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil)
- r.set("git", []string{"init"}, "", nil)
-
- // The suggested repo name already exists.
- repoName := filepath.Base(dir)
- r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil)
-
- opts := GitHubBootstrapOptions{Yes: true}
- err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r)
- if err == nil {
- t.Fatal("expected error when repo name exists and no TTY")
- }
- if !strings.Contains(err.Error(), "already exists") {
- t.Errorf("expected 'already exists' in error, got: %v", err)
- }
-}
-
-func TestResolveRepoName_YesRepoExistsWithTTY_FallsBackToPrompt(t *testing.T) {
- // When --yes is set, the name is taken, and a TTY is available,
- // resolveRepoName should print a conflict message and fall through
- // to the interactive prompt. We verify the conflict message was
- // printed (proving the fallback path was taken).
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // Force accessible (text-based) mode so the huh form reads from
- // os.Stdin instead of trying to open /dev/tty via bubbletea.
- // Pipe a unique name so the form completes instead of blocking.
- t.Setenv("ACCESSIBLE", "1")
- pr, pw, err := os.Pipe()
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { pr.Close() })
- go func() {
- // The form reads one line; provide a unique name so it exits the loop.
- pw.WriteString("unique-test-repo\n") //nolint:errcheck // test helper
- pw.Close()
- }()
- oldStdin := os.Stdin
- os.Stdin = pr
- t.Cleanup(func() { os.Stdin = oldStdin })
-
- dir := t.TempDir()
- restoreCwd(t, dir)
-
- r := newFakeRunner()
- repoName := filepath.Base(dir)
- // The suggested name exists.
- r.set("gh", []string{"repo", "view", "myuser/" + repoName, "--json", "name"}, `{"name":"`+repoName+`"}`, nil)
- // The unique name typed at the prompt does not exist (fakeRunner returns
- // an error for unknown calls, which ghRepoExists treats as "proceed").
-
- var stdout bytes.Buffer
- opts := GitHubBootstrapOptions{Yes: true}
- name, err := resolveRepoName(context.Background(), &stdout, io.Discard, r, "myuser", dir, opts)
-
- output := stdout.String()
- if !strings.Contains(output, "already exists on GitHub") {
- t.Errorf("expected conflict message in output, got: %s", output)
- }
- // The form should complete with the unique name (fakeRunner can't verify
- // the name, so resolveRepoName proceeds with a warning).
- if err != nil {
- t.Errorf("unexpected error: %v", err)
- }
- if name != "unique-test-repo" {
- t.Errorf("expected name %q, got %q", "unique-test-repo", name)
- }
-}
-
-func TestRunGitHubBootstrap_YesWithNoGitHub(t *testing.T) {
- // --yes combined with --no-github should skip GitHub but still init + commit.
- dir := t.TempDir()
- restoreCwd(t, dir)
-
- r := newFakeRunner()
- r.setIdentityConfigured()
- r.set("git", []string{"init"}, "", nil)
- r.set("git", []string{"add", "-A"}, "", nil)
- r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
- r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"}, "", nil)
-
- opts := GitHubBootstrapOptions{Yes: true, NoGitHub: true}
- err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Should NOT have called gh at all
- if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) {
- t.Error("expected no gh calls with --no-github")
- }
- // Should have committed
- if !r.hasCall(argsMatch("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Initial commit"})) {
- t.Error("expected commit with default message")
- }
-}
diff --git a/cli/setup_test.go b/cli/setup_test.go
index 80a66cb..f352450 100644
--- a/cli/setup_test.go
+++ b/cli/setup_test.go
@@ -3,23 +3,17 @@ package cli
import (
"bytes"
"context"
- "encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
- "slices"
"strings"
"testing"
- "github.com/GrayCodeAI/trace/cli/agent"
_ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
- "github.com/GrayCodeAI/trace/cli/agent/external"
_ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
- "github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
- "github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/strategy"
"github.com/GrayCodeAI/trace/cli/testutil"
)
@@ -788,2162 +782,3 @@ func TestShellCompletionTarget(t *testing.T) {
})
}
}
-
-func TestAppendShellCompletion(t *testing.T) {
- tests := []struct {
- name string
- rcFileRelPath string
- completionLine string
- preExisting string // existing content in rc file; empty means file doesn't exist
- createParent bool // whether parent dir already exists
- }{
- {
- name: "zsh_new_file",
- rcFileRelPath: ".zshrc",
- completionLine: "source <(trace completion zsh)",
- createParent: true,
- },
- {
- name: "zsh_existing_file",
- rcFileRelPath: ".zshrc",
- completionLine: "source <(trace completion zsh)",
- preExisting: "# existing zshrc content\n",
- createParent: true,
- },
- {
- name: "fish_no_parent_dir",
- rcFileRelPath: filepath.Join(".config", "fish", "config.fish"),
- completionLine: "trace completion fish | source",
- createParent: false,
- },
- {
- name: "fish_existing_dir",
- rcFileRelPath: filepath.Join(".config", "fish", "config.fish"),
- completionLine: "trace completion fish | source",
- createParent: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- home := t.TempDir()
- rcFile := filepath.Join(home, tt.rcFileRelPath)
-
- if tt.createParent {
- if err := os.MkdirAll(filepath.Dir(rcFile), 0o755); err != nil {
- t.Fatal(err)
- }
- }
- if tt.preExisting != "" {
- if err := os.WriteFile(rcFile, []byte(tt.preExisting), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- if err := appendShellCompletion(rcFile, tt.completionLine); err != nil {
- t.Fatalf("appendShellCompletion() error: %v", err)
- }
-
- // Verify the file was created and contains the completion line.
- data, err := os.ReadFile(rcFile)
- if err != nil {
- t.Fatalf("reading rc file: %v", err)
- }
- content := string(data)
-
- if !strings.Contains(content, shellCompletionComment) {
- t.Errorf("rc file missing comment %q", shellCompletionComment)
- }
- if !strings.Contains(content, tt.completionLine) {
- t.Errorf("rc file missing completion line %q", tt.completionLine)
- }
- if tt.preExisting != "" && !strings.HasPrefix(content, tt.preExisting) {
- t.Errorf("pre-existing content was overwritten")
- }
-
- // Verify parent directory permissions.
- info, err := os.Stat(filepath.Dir(rcFile))
- if err != nil {
- t.Fatalf("stat parent dir: %v", err)
- }
- if !info.IsDir() {
- t.Fatal("parent path is not a directory")
- }
- })
- }
-}
-
-func TestRemoveTraceDirectory_NotExists(t *testing.T) {
- setupTestDir(t)
-
- // Should not error when directory doesn't exist
- if err := removeTraceDirectory(context.Background()); err != nil {
- t.Fatalf("removeTraceDirectory(context.Background()) should not error when directory doesn't exist: %v", err)
- }
-}
-
-func TestPrintMissingAgentError(t *testing.T) {
- t.Parallel()
-
- var buf bytes.Buffer
- printMissingAgentError(&buf)
- output := buf.String()
-
- if !strings.Contains(output, "Missing agent name") {
- t.Error("expected 'Missing agent name' in output")
- }
- for _, a := range agent.List() {
- if !strings.Contains(output, string(a)) {
- t.Errorf("expected agent %q listed in output", a)
- }
- }
- if !strings.Contains(output, "(default)") {
- t.Error("expected default annotation in output")
- }
- if !strings.Contains(output, "Usage: trace enable --agent") {
- t.Error("expected usage line in output")
- }
-}
-
-func TestPrintWrongAgentError(t *testing.T) {
- t.Parallel()
-
- var buf bytes.Buffer
- printWrongAgentError(&buf, "not-an-agent")
- output := buf.String()
-
- if !strings.Contains(output, `Unknown agent "not-an-agent"`) {
- t.Error("expected unknown agent name in output")
- }
- for _, a := range agent.List() {
- if !strings.Contains(output, string(a)) {
- t.Errorf("expected agent %q listed in output", a)
- }
- }
- if !strings.Contains(output, "(default)") {
- t.Error("expected default annotation in output")
- }
- if !strings.Contains(output, "Usage: trace enable --agent") {
- t.Error("expected usage line in output")
- }
-}
-
-func TestEnableCmd_AgentFlagNoValue(t *testing.T) {
- setupTestRepo(t)
-
- cmd := newEnableCmd()
- var stderr bytes.Buffer
- cmd.SetErr(&stderr)
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetArgs([]string{"--agent"})
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error when --agent is used without a value")
- }
-
- output := stderr.String()
- if !strings.Contains(output, "Missing agent name") {
- t.Errorf("expected helpful error message, got: %s", output)
- }
- if !strings.Contains(output, string(agent.DefaultAgentName)) {
- t.Errorf("expected default agent listed, got: %s", output)
- }
- if strings.Contains(output, "flag needs an argument") {
- t.Error("should not contain default cobra/pflag error message")
- }
-}
-
-func TestEnableCmd_AgentFlagEmptyValue(t *testing.T) {
- setupTestRepo(t)
-
- cmd := newEnableCmd()
- var stderr bytes.Buffer
- cmd.SetErr(&stderr)
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetArgs([]string{"--agent="})
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error when --agent= is used with empty value")
- }
-
- output := stderr.String()
- if !strings.Contains(output, "Missing agent name") {
- t.Errorf("expected helpful error message, got: %s", output)
- }
- if strings.Contains(output, "flag needs an argument") {
- t.Error("should not contain default cobra/pflag error message")
- }
-}
-
-func TestEnableUsesSetupFlow(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- args []string
- agentName string
- want bool
- }{
- {name: "bare enable", args: nil, want: false},
- {name: "project only", args: []string{"--project"}, want: false},
- {name: "local only", args: []string{"--local"}, want: false},
- {name: "force", args: []string{"--force"}, want: true},
- {name: "local dev", args: []string{"--local-dev"}, want: true},
- {name: "absolute hook path", args: []string{"--absolute-git-hook-path"}, want: true},
- {name: "telemetry changed", args: []string{"--telemetry=false"}, want: true},
- {name: "checkpoint remote", args: []string{"--checkpoint-remote", "github:org/repo"}, want: true},
- {name: "skip push sessions", args: []string{"--skip-push-sessions"}, want: true},
- {name: "agent flag", args: []string{"--agent", "claude-code"}, agentName: "claude-code", want: true},
- {name: "yes flag", args: []string{"--yes"}, want: true},
- {name: "yes short flag", args: []string{"-y"}, want: true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- cmd := newEnableCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs(tt.args)
- if err := cmd.ParseFlags(tt.args); err != nil {
- t.Fatalf("ParseFlags() error = %v", err)
- }
-
- if got := enableUsesSetupFlow(cmd, tt.agentName); got != tt.want {
- t.Fatalf("enableUsesSetupFlow(%v, %q) = %v, want %v", tt.args, tt.agentName, got, tt.want)
- }
- })
- }
-}
-
-func TestEnableCmd_ForceOnConfiguredRepo_UsesConfigureFlow(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- cmd := newEnableCmd()
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable --force error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
- t.Fatalf("expected enable --force to route to configure flow, got: %s", output)
- }
- if strings.Contains(output, "Trace is already enabled.") {
- t.Fatalf("expected enable --force to avoid the lightweight re-enable path, got: %s", output)
- }
-}
-
-func TestEnableCmd_ForceOnConfiguredDisabledRepo_Reenables(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsDisabled)
- writeClaudeHooksFixture(t)
-
- cmd := newEnableCmd()
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--force"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable --force error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
- t.Fatalf("expected enable --force to route through manage agents before enabling, got: %s", output)
- }
- if !strings.Contains(output, "Trace is now enabled.") {
- t.Fatalf("expected enable --force to still enable the repo, got: %s", output)
- }
-
- enabled, err := IsEnabled(context.Background())
- if err != nil {
- t.Fatalf("IsEnabled() error = %v", err)
- }
- if !enabled {
- t.Fatal("expected repo to be enabled after enable --force")
- }
-}
-
-func TestEnableCmd_ForceAndStrategyFlagsOnConfiguredDisabledRepo_ReenablesAndUpdatesSettings(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsDisabled)
- writeClaudeHooksFixture(t)
-
- cmd := newEnableCmd()
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--force", "--checkpoint-remote", "github:org/repo", "--skip-push-sessions"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable with force and strategy flags error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Settings updated") {
- t.Fatalf("expected strategy flags to be applied, got: %s", output)
- }
- if !strings.Contains(output, "Cannot show agent selection in non-interactive mode.") {
- t.Fatalf("expected force handling to still reach manage agents, got: %s", output)
- }
- if !strings.Contains(output, "Trace is now enabled.") {
- t.Fatalf("expected repo to be enabled after updating settings, got: %s", output)
- }
-
- enabled, err := IsEnabled(context.Background())
- if err != nil {
- t.Fatalf("IsEnabled() error = %v", err)
- }
- if !enabled {
- t.Fatal("expected repo to be enabled after enable with strategy flags")
- }
-
- s, err := LoadTraceSettings(context.Background())
- if err != nil {
- t.Fatalf("LoadTraceSettings() error = %v", err)
- }
- if got := s.StrategyOptions["push_sessions"]; got != false {
- t.Fatalf("push_sessions = %v, want false", got)
- }
- checkpointRemote, ok := s.StrategyOptions["checkpoint_remote"].(map[string]interface{})
- if !ok {
- t.Fatalf("checkpoint_remote = %#v, want map", s.StrategyOptions["checkpoint_remote"])
- }
- if checkpointRemote["provider"] != "github" || checkpointRemote["repo"] != "org/repo" {
- t.Fatalf("checkpoint_remote = %#v, want github/org/repo", checkpointRemote)
- }
-}
-
-// Tests for detectOrSelectAgent
-
-func TestDetectOrSelectAgent_AgentDetected(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
-
- // Create .claude directory so Claude Code agent is detected
- if err := os.MkdirAll(".claude", 0o755); err != nil {
- t.Fatalf("Failed to create .claude directory: %v", err)
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should detect Claude Code
- if len(agents) != 1 {
- t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
- }
- if agents[0].Name() != agent.AgentNameClaudeCode {
- t.Errorf("detectOrSelectAgent() agent name = %v, want %v", agents[0].Name(), agent.AgentNameClaudeCode)
- }
-
- output := buf.String()
- if !strings.Contains(output, "Detected agent:") {
- t.Errorf("Expected output to contain 'Detected agent:', got: %s", output)
- }
- if !strings.Contains(output, string(agent.AgentTypeClaudeCode)) {
- t.Errorf("Expected output to contain '%s', got: %s", agent.AgentTypeClaudeCode, output)
- }
-}
-
-func TestDetectOrSelectAgent_GeminiDetected(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
-
- // Create .gemini directory so Gemini agent is detected
- if err := os.MkdirAll(".gemini", 0o755); err != nil {
- t.Fatalf("Failed to create .gemini directory: %v", err)
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should detect Gemini
- if len(agents) != 1 {
- t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
- }
- if agents[0].Name() != agent.AgentNameGemini {
- t.Errorf("detectOrSelectAgent() agent name = %v, want %v", agents[0].Name(), agent.AgentNameGemini)
- }
-
- output := buf.String()
- if !strings.Contains(output, "Detected agent:") {
- t.Errorf("Expected output to contain 'Detected agent:', got: %s", output)
- }
-}
-
-func TestDetectOrSelectAgent_OnlyExternalDetected_WithTTY_PromptsUser(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir, t.Setenv, and global agent registration
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- externalAgentName := "ext-prompt-pi"
- externalDir := t.TempDir()
- writeExternalAgentBinary(t, externalDir, externalAgentName)
- t.Setenv("TRACE_TEST_EXTERNAL_PRESENT", "1")
- t.Setenv("PATH", externalDir)
-
- external.DiscoverAndRegisterAlways(context.Background())
-
- var receivedAvailable []string
- selectFn := func(available []string) ([]string, error) {
- receivedAvailable = available
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- if len(receivedAvailable) == 0 {
- t.Fatal("Expected interactive prompt when only an external agent is detected")
- }
- if !slices.Contains(receivedAvailable, externalAgentName) {
- t.Fatalf("Expected external agent %q in options, got %v", externalAgentName, receivedAvailable)
- }
- if !slices.Contains(receivedAvailable, string(agent.AgentNameClaudeCode)) {
- t.Fatalf("Expected built-in agent options alongside external agent, got %v", receivedAvailable)
- }
- if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
- t.Fatalf("Expected selected Claude Code agent, got %v", agents)
- }
- if strings.Contains(buf.String(), "Detected agent:") {
- t.Errorf("Expected external-only detection to prompt instead of auto-selecting, got output: %s", buf.String())
- }
-}
-
-func TestIsBuiltInAgent_ExternalAgent_False(t *testing.T) {
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- setupTestRepo(t)
-
- externalAgentName := "ext-preselect-pi"
- externalDir := t.TempDir()
- writeExternalAgentBinary(t, externalDir, externalAgentName)
- t.Setenv("TRACE_TEST_EXTERNAL_PRESENT", "1")
- t.Setenv("PATH", externalDir)
-
- external.DiscoverAndRegisterAlways(context.Background())
-
- externalAgent, err := agent.Get(types.AgentName(externalAgentName))
- if err != nil {
- t.Fatalf("failed to get external agent %q: %v", externalAgentName, err)
- }
-
- if isBuiltInAgent(externalAgent) {
- t.Fatalf("expected external agent %q to not be treated as built-in", externalAgentName)
- }
-}
-
-func TestIsBuiltInAgent_BuiltInAgent_True(t *testing.T) {
- t.Parallel()
-
- claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
- if err != nil {
- t.Fatalf("failed to get claude agent: %v", err)
- }
-
- if !isBuiltInAgent(claudeAgent) {
- t.Fatal("expected built-in agent to be treated as built-in")
- }
-}
-
-func TestDetectOrSelectAgent_NoDetection_NoTTY_FallsBackToDefault(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
-
- // No .claude or .gemini directory - detection will fail
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should fall back to default agent (Claude Code)
- if len(agents) != 1 {
- t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
- }
- if agents[0].Name() != agent.DefaultAgentName {
- t.Errorf("detectOrSelectAgent() agent name = %v, want default %v", agents[0].Name(), agent.DefaultAgentName)
- }
-
- output := buf.String()
- if !strings.Contains(output, "Agent:") {
- t.Errorf("Expected output to contain 'Agent:', got: %s", output)
- }
- if !strings.Contains(output, "(use --agent to change)") {
- t.Errorf("Expected output to contain '(use --agent to change)', got: %s", output)
- }
-}
-
-func TestDetectOrSelectAgent_NoDetection_WithTTY_ShowsPromptMessages(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // No .claude or .gemini directory - detection will fail
-
- // Inject selector to avoid blocking on interactive form.Run().
- // The selector receives available agent names so tests can validate the options.
- selectFn := func(available []string) ([]string, error) {
- if len(available) == 0 {
- t.Error("selectFn received no available agents")
- }
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should return the mock-selected agent
- if len(agents) != 1 {
- t.Fatalf("detectOrSelectAgent() returned %d agents, want 1", len(agents))
- }
- if agents[0].Name() != agent.AgentNameClaudeCode {
- t.Errorf("detectOrSelectAgent() agent = %v, want %v", agents[0].Name(), agent.AgentNameClaudeCode)
- }
-
- output := buf.String()
- if !strings.Contains(output, "Selected agents:") {
- t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
- }
-}
-
-func TestDetectOrSelectAgent_SelectionCancelled(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- selectFn := func(_ []string) ([]string, error) {
- return nil, errors.New("user cancelled")
- }
-
- var buf bytes.Buffer
- _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err == nil {
- t.Fatal("expected error when selection is cancelled")
- }
- if !strings.Contains(err.Error(), "user cancelled") {
- t.Errorf("expected 'user cancelled' in error, got: %v", err)
- }
-}
-
-func TestDetectOrSelectAgent_NoneSelected(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{}, nil // user deselected everything
- }
-
- var buf bytes.Buffer
- _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err == nil {
- t.Fatal("expected error when no agents selected")
- }
- if !strings.Contains(err.Error(), "no agents selected") {
- t.Errorf("expected 'no agents selected' in error, got: %v", err)
- }
-}
-
-func TestDetectOrSelectAgent_BothDirectoriesExist_PromptsUser(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // Create both .claude and .gemini directories
- if err := os.MkdirAll(".claude", 0o755); err != nil {
- t.Fatalf("Failed to create .claude directory: %v", err)
- }
- if err := os.MkdirAll(".gemini", 0o755); err != nil {
- t.Fatalf("Failed to create .gemini directory: %v", err)
- }
-
- // Inject selector — receives available names, returns both
- selectFn := func(available []string) ([]string, error) {
- if len(available) < 2 {
- t.Errorf("expected at least 2 available agents, got %d", len(available))
- }
- return []string{string(agent.AgentNameClaudeCode), string(agent.AgentNameGemini)}, nil
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should return both selected agents
- if len(agents) != 2 {
- t.Fatalf("detectOrSelectAgent() returned %d agents, want 2", len(agents))
- }
-
- output := buf.String()
- if !strings.Contains(output, "Detected multiple agents:") {
- t.Errorf("Expected output to contain 'Detected multiple agents:', got: %s", output)
- }
- if !strings.Contains(output, "Claude Code") {
- t.Errorf("Expected output to mention Claude Code, got: %s", output)
- }
- if !strings.Contains(output, "Gemini CLI") {
- t.Errorf("Expected output to mention Gemini CLI, got: %s", output)
- }
- if !strings.Contains(output, "Selected agents:") {
- t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
- }
-}
-
-func TestDetectOrSelectAgent_BothDirectoriesExist_NoTTY_UsesAll(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
-
- // Create both .claude and .gemini directories
- if err := os.MkdirAll(".claude", 0o755); err != nil {
- t.Fatalf("Failed to create .claude directory: %v", err)
- }
- if err := os.MkdirAll(".gemini", 0o755); err != nil {
- t.Fatalf("Failed to create .gemini directory: %v", err)
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // With no TTY and multiple detected, should return all detected agents
- if len(agents) != 2 {
- t.Errorf("detectOrSelectAgent() returned %d agents, want 2", len(agents))
- }
-}
-
-// writeClaudeHooksFixture writes a minimal .claude/settings.json with Trace hooks installed.
-// Only the Stop hook is needed — AreHooksInstalled() checks for it first.
-func writeClaudeHooksFixture(t *testing.T) {
- t.Helper()
- if err := os.MkdirAll(".claude", 0o755); err != nil {
- t.Fatalf("Failed to create .claude directory: %v", err)
- }
- hooksJSON := `{
- "hooks": {
- "Stop": [{"hooks": [{"type": "command", "command": "trace hooks claude-code stop"}]}]
- }
- }`
- if err := os.WriteFile(".claude/settings.json", []byte(hooksJSON), 0o644); err != nil {
- t.Fatalf("Failed to write .claude/settings.json: %v", err)
- }
-}
-
-// writeGeminiHooksFixture writes a minimal .gemini/settings.json with Trace hooks installed.
-// AreHooksInstalled() checks for any hook command starting with "trace ".
-func writeGeminiHooksFixture(t *testing.T) {
- t.Helper()
- if err := os.MkdirAll(".gemini", 0o755); err != nil {
- t.Fatalf("Failed to create .gemini directory: %v", err)
- }
- hooksJSON := `{
- "hooks": {
- "enabled": true,
- "SessionStart": [{"hooks": [{"type": "command", "command": "trace hooks gemini session-start"}]}]
- }
- }`
- if err := os.WriteFile(".gemini/settings.json", []byte(hooksJSON), 0o644); err != nil {
- t.Fatalf("Failed to write .gemini/settings.json: %v", err)
- }
-}
-
-func TestDetectOrSelectAgent_ReRun_AlwaysPromptsWithInstalledPreSelected(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // Install Claude Code hooks (simulates a previous `trace enable` run)
- writeClaudeHooksFixture(t)
-
- // Verify hooks are detected as installed
- installed := GetAgentsWithHooksInstalled(context.Background())
- if len(installed) == 0 {
- t.Fatal("Expected Claude Code hooks to be detected as installed")
- }
-
- // Track what the selector receives
- var receivedAvailable []string
- selectFn := func(available []string) ([]string, error) {
- receivedAvailable = available
- // User keeps claude-code selected
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should have been prompted (selectFn called) even though only one agent is detected
- if len(receivedAvailable) == 0 {
- t.Fatal("Expected interactive prompt to be shown on re-run, but selectFn was not called")
- }
-
- // Should return the selected agent
- if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
- t.Errorf("Expected [claude-code], got %v", agents)
- }
-
- // Should NOT contain "Detected agent:" (the auto-use message for first run)
- output := buf.String()
- if strings.Contains(output, "Detected agent:") {
- t.Errorf("Re-run should not auto-use agent, but got: %s", output)
- }
-}
-
-func TestDetectOrSelectAgent_ReRun_NoTTY_KeepsInstalled(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
-
- // Install Claude Code hooks
- writeClaudeHooksFixture(t)
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should keep currently installed agents without prompting
- if len(agents) != 1 {
- t.Fatalf("Expected 1 agent, got %d", len(agents))
- }
- if agents[0].Name() != agent.AgentNameClaudeCode {
- t.Errorf("Expected claude-code, got %v", agents[0].Name())
- }
-}
-
-// checkClaudeCodeHooksInstalled checks if Claude Code hooks are installed.
-func checkClaudeCodeHooksInstalled() bool {
- ag, err := agent.Get(agent.AgentNameClaudeCode)
- if err != nil {
- return false
- }
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- return false
- }
- return hookAgent.AreHooksInstalled(context.Background())
-}
-
-// checkGeminiCLIHooksInstalled checks if Gemini CLI hooks are installed.
-func checkGeminiCLIHooksInstalled() bool {
- ag, err := agent.Get(agent.AgentNameGemini)
- if err != nil {
- return false
- }
- hookAgent, ok := agent.AsHookSupport(ag)
- if !ok {
- return false
- }
- return hookAgent.AreHooksInstalled(context.Background())
-}
-
-func TestUninstallDeselectedAgentHooks(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
-
- // Install Claude Code hooks
- writeClaudeHooksFixture(t)
-
- // Verify hooks are installed
- if !checkClaudeCodeHooksInstalled() {
- t.Fatal("Expected Claude Code hooks to be installed before test")
- }
-
- // Call uninstallDeselectedAgentHooks with an empty selection (deselect claude-code)
- var buf bytes.Buffer
- err := uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{})
- if err != nil {
- t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
- }
-
- // Hooks should be uninstalled
- if checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to be uninstalled after deselection")
- }
-
- output := buf.String()
- if !strings.Contains(output, "Removed") {
- t.Errorf("Expected output to mention removal, got: %s", output)
- }
-}
-
-func TestUninstallDeselectedAgentHooks_KeepsSelectedAgents(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
-
- // Install Claude Code hooks
- writeClaudeHooksFixture(t)
-
- // Call uninstallDeselectedAgentHooks with claude-code still selected
- claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
- if err != nil {
- t.Fatalf("Failed to get claude-code agent: %v", err)
- }
-
- var buf bytes.Buffer
- err = uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{claudeAgent})
- if err != nil {
- t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
- }
-
- // Hooks should still be installed
- if !checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to remain installed when still selected")
- }
-
- output := buf.String()
- if strings.Contains(output, "Removed") {
- t.Errorf("Should not mention removal when agent is still selected, got: %s", output)
- }
-}
-
-func TestUninstallDeselectedAgentHooks_MultipleInstalled_DeselectOne(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
-
- // Install both Claude Code and Gemini hooks
- writeClaudeHooksFixture(t)
- writeGeminiHooksFixture(t)
-
- // Verify both are installed
- installed := GetAgentsWithHooksInstalled(context.Background())
- if len(installed) < 2 {
- t.Fatalf("Expected at least 2 agents installed, got %d", len(installed))
- }
-
- // Keep only Claude Code selected (deselect Gemini)
- claudeAgent, err := agent.Get(agent.AgentNameClaudeCode)
- if err != nil {
- t.Fatalf("Failed to get claude-code agent: %v", err)
- }
-
- var buf bytes.Buffer
- err = uninstallDeselectedAgentHooks(context.Background(), &buf, []agent.Agent{claudeAgent})
- if err != nil {
- t.Fatalf("uninstallDeselectedAgentHooks() error = %v", err)
- }
-
- // Claude Code hooks should remain
- if !checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to remain installed")
- }
-
- // Gemini hooks should be removed
- if checkGeminiCLIHooksInstalled() {
- t.Error("Expected Gemini CLI hooks to be uninstalled after deselection")
- }
-
- output := buf.String()
- if !strings.Contains(output, "Removed") {
- t.Errorf("Expected output to mention removal, got: %s", output)
- }
-}
-
-func TestManageAgents_DeselectRemovesAgent(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
-
- // Install Claude Code hooks
- writeClaudeHooksFixture(t)
-
- if !checkClaudeCodeHooksInstalled() {
- t.Fatal("Expected Claude Code hooks to be installed before test")
- }
-
- // Deselect claude-code, select gemini instead
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameGemini)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- output := buf.String()
-
- // Claude Code hooks should be removed
- if checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to be uninstalled after deselection")
- }
-
- if !strings.Contains(output, "Removed agents") {
- t.Errorf("Expected output to mention removed agents, got: %s", output)
- }
-}
-
-func TestManageAgents_DeselectAll_RemovesAllAndShowsGuidance(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- if !checkClaudeCodeHooksInstalled() {
- t.Fatal("Expected Claude Code hooks to be installed before test")
- }
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "All agents have been removed.") {
- t.Errorf("Expected 'All agents have been removed.' message, got: %s", output)
- }
- if !strings.Contains(output, "trace agent add") {
- t.Errorf("Expected guidance on how to re-add agents, got: %s", output)
- }
-
- if checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to be uninstalled after deselecting all")
- }
-}
-
-func TestManageAgents_NoChanges(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- // Keep the same selection
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- if !strings.Contains(buf.String(), "No changes made.") {
- t.Errorf("Expected 'No changes made.' output, got: %s", buf.String())
- }
-}
-
-func TestManageAgents_NoChanges_StillPersistsVercelSetting(t *testing.T) {
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- if err := os.WriteFile("vercel.json", []byte(`{
- "git": {
- "deploymentEnabled": {
- "trace/**": false
- }
- }
-}`), 0o644); err != nil {
- t.Fatalf("write vercel.json: %v", err)
- }
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- if strings.Contains(buf.String(), "No changes made.") {
- t.Fatalf("did not expect no-op output when settings changed, got: %s", buf.String())
- }
- if !strings.Contains(buf.String(), ".trace/settings.json") {
- t.Fatalf("expected settings update output, got: %s", buf.String())
- }
-
- s, err := settings.Load(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if !s.Vercel {
- t.Fatal("expected vercel setting to be enabled")
- }
-}
-
-func TestManageAgents_ForceReinstallsSelectedAgentHooks(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- // Simulate a stale or locally modified Trace-managed Claude hook.
- modifiedHooksJSON := `{
- "hooks": {
- "Stop": [{"hooks": [{"type": "command", "command": "trace hooks claude-code stop --stale"}]}]
- }
- }`
- if err := os.WriteFile(".claude/settings.json", []byte(modifiedHooksJSON), 0o644); err != nil {
- t.Fatalf("Failed to mutate .claude/settings.json: %v", err)
- }
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{ForceHooks: true}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- data, err := os.ReadFile(".claude/settings.json")
- if err != nil {
- t.Fatalf("Failed to read .claude/settings.json: %v", err)
- }
- content := string(data)
-
- if strings.Contains(content, "stop --stale") {
- t.Errorf("Expected force reinstall to rewrite stale Claude hook, got: %s", content)
- }
- if !strings.Contains(content, "trace hooks claude-code stop") {
- t.Errorf("Expected force reinstall to restore canonical Claude hook, got: %s", content)
- }
- if strings.Contains(buf.String(), "No changes made.") {
- t.Errorf("Force reinstall should not be treated as no-op, got: %s", buf.String())
- }
-}
-
-func TestManageAgents_ForceReportsReinstalledAgentsSeparately(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{ForceHooks: true}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- if !strings.Contains(buf.String(), "Reinstalled agents") {
- t.Errorf("Expected force reinstall summary to mention reinstalled agents, got: %s", buf.String())
- }
- if strings.Contains(buf.String(), "Added agents") {
- t.Errorf("Force reinstall should not be reported as added agents, got: %s", buf.String())
- }
-}
-
-func TestManageAgents_AddAndRemove(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
- writeSettings(t, testSettingsEnabled)
-
- // Install Claude Code hooks
- writeClaudeHooksFixture(t)
-
- // Deselect claude-code, add gemini
- selectFn := func(_ []string) ([]string, error) {
- return []string{string(agent.AgentNameGemini)}, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectFn)
- if err != nil {
- t.Fatalf("runManageAgents() error = %v", err)
- }
-
- output := buf.String()
- if !strings.Contains(output, "Added agents") {
- t.Errorf("Expected 'Added agents' in output, got: %s", output)
- }
- if !strings.Contains(output, "Removed agents") {
- t.Errorf("Expected 'Removed agents' in output, got: %s", output)
- }
-
- // Verify hooks on disk: Claude removed, Gemini added
- if checkClaudeCodeHooksInstalled() {
- t.Error("Expected Claude Code hooks to be uninstalled after deselection")
- }
- if !checkGeminiCLIHooksInstalled() {
- t.Error("Expected Gemini CLI hooks to be installed after selection")
- }
-}
-
-func TestMaybePromptVercelDeploymentDisable_MergesExistingConfig(t *testing.T) {
- setupTestRepo(t)
-
- requireWriteFile := func(path, content string) {
- t.Helper()
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatalf("write %s: %v", path, err)
- }
- }
-
- requireWriteFile("vercel.json", `{
- "cleanUrls": true,
- "git": {
- "deploymentEnabled": {
- "main": true
- }
- }
-}`)
-
- var prompted bool
- var buf bytes.Buffer
- changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
- prompted = true
- return true, nil
- })
- if err != nil {
- t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
- }
- if !changed {
- t.Fatal("expected Vercel setting change")
- }
- if !prompted {
- t.Fatal("expected Vercel prompt to run")
- }
-
- projectSettings, err := settings.Load(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if !projectSettings.Vercel {
- t.Fatal("expected vercel setting to be enabled")
- }
-}
-
-func TestMaybePromptVercelDeploymentDisable_CreatesConfigWhenVercelDetected(t *testing.T) {
- setupTestRepo(t)
-
- if err := os.MkdirAll(".vercel", 0o755); err != nil {
- t.Fatalf("mkdir .vercel: %v", err)
- }
-
- var buf bytes.Buffer
- changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
- return true, nil
- })
- if err != nil {
- t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
- }
- if !changed {
- t.Fatal("expected Vercel setting change")
- }
-
- projectSettings, err := settings.Load(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if !projectSettings.Vercel {
- t.Fatal("expected vercel setting to be enabled")
- }
-}
-
-func TestMaybePromptVercelDeploymentDisable_SkipsPromptWhenAlreadyDisabledInVercelJSON(t *testing.T) {
- setupTestRepo(t)
-
- if err := os.WriteFile("vercel.json", []byte(`{
- "git": {
- "deploymentEnabled": {
- "trace/**": false
- }
- }
-}`), 0o644); err != nil {
- t.Fatalf("write vercel.json: %v", err)
- }
-
- promptCalled := false
- var buf bytes.Buffer
- changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsFile, func() (bool, error) {
- promptCalled = true
- return true, nil
- })
- if err != nil {
- t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
- }
- if !changed {
- t.Fatal("expected Vercel setting change from existing vercel.json")
- }
- if promptCalled {
- t.Fatal("expected Vercel prompt to be skipped when already configured")
- }
- if !strings.Contains(buf.String(), ".trace/settings.json") {
- t.Fatalf("expected settings update output, got %q", buf.String())
- }
-
- projectSettings, err := settings.Load(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if !projectSettings.Vercel {
- t.Fatal("expected vercel setting to be enabled from existing vercel.json")
- }
-}
-
-func TestMaybePromptVercelDeploymentDisable_WritesLocalSettingsWhenRequested(t *testing.T) {
- setupTestRepo(t)
-
- if err := os.MkdirAll(filepath.Dir(settings.TraceSettingsLocalFile), 0o755); err != nil {
- t.Fatalf("mkdir settings dir: %v", err)
- }
- if err := os.WriteFile("vercel.json", []byte(`{}`), 0o644); err != nil {
- t.Fatalf("write vercel.json: %v", err)
- }
-
- var buf bytes.Buffer
- changed, err := maybePromptVercelDeploymentDisable(context.Background(), &buf, settings.TraceSettingsLocalFile, func() (bool, error) {
- return true, nil
- })
- if err != nil {
- t.Fatalf("maybePromptVercelDeploymentDisable() error = %v", err)
- }
- if !changed {
- t.Fatal("expected Vercel setting change")
- }
- if !strings.Contains(buf.String(), settings.TraceSettingsLocalFile) {
- t.Fatalf("expected local settings update output, got %q", buf.String())
- }
-
- localSettingsPath := filepath.Join(".", settings.TraceSettingsLocalFile)
- localSettings, err := settings.LoadFromFile(localSettingsPath)
- if err != nil {
- t.Fatalf("load local settings: %v", err)
- }
- if !localSettings.Vercel {
- t.Fatal("expected vercel setting in local settings")
- }
-
- projectSettingsPath := filepath.Join(".", settings.TraceSettingsFile)
- projectSettings, err := settings.LoadFromFile(projectSettingsPath)
- if err != nil {
- t.Fatalf("load project settings: %v", err)
- }
- if projectSettings.Vercel {
- t.Fatal("expected project settings to remain unchanged")
- }
-}
-
-func TestDetectOrSelectAgent_ReRun_NewlyDetectedAgentAvailableNotPreSelected(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // Simulate: Claude Code hooks installed from a previous run
- writeClaudeHooksFixture(t)
-
- // Simulate: user added .gemini directory since last enable (detected but not installed)
- if err := os.MkdirAll(".gemini", 0o755); err != nil {
- t.Fatalf("Failed to create .gemini directory: %v", err)
- }
-
- // Track which agents the selector receives
- var receivedAvailable []string
- selectFn := func(available []string) ([]string, error) {
- receivedAvailable = available
- // Only select the installed agent (simulate user not checking the new one)
- return []string{string(agent.AgentNameClaudeCode)}, nil
- }
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() error = %v", err)
- }
-
- // Should have prompted (re-run always prompts)
- if len(receivedAvailable) == 0 {
- t.Fatal("Expected interactive prompt on re-run")
- }
-
- // Newly detected agent should be available as an option
- if len(receivedAvailable) < 2 {
- t.Errorf("Expected at least 2 available agents (detected agent should be an option), got %d", len(receivedAvailable))
- }
-
- // Only the installed agent should be returned (user didn't select the new one)
- if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
- t.Errorf("Expected only [claude-code], got %v", agents)
- }
-}
-
-func TestDetectOrSelectAgent_ReRun_EmptySelection_ReturnsError(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- // Install Claude Code hooks (re-run scenario)
- writeClaudeHooksFixture(t)
-
- selectFn := func(_ []string) ([]string, error) {
- return []string{}, nil // user deselected everything
- }
-
- var buf bytes.Buffer
- _, err := detectOrSelectAgent(context.Background(), &buf, selectFn)
- if err == nil {
- t.Fatal("Expected error when no agents selected on re-run")
- }
- if !strings.Contains(err.Error(), "no agents selected") {
- t.Errorf("Expected 'no agents selected' error, got: %v", err)
- }
-}
-
-// Tests for configure --checkpoint-remote
-
-func TestConfigureCmd_CheckpointRemote_UpdatesProjectSettings(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--checkpoint-remote", "github:ashtom/zeugs-checkpoints"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --checkpoint-remote failed: %v", err)
- }
-
- if !strings.Contains(stdout.String(), "Settings updated") {
- t.Errorf("expected 'Settings updated' output, got: %s", stdout.String())
- }
-
- // Verify the setting was written to settings.json
- s, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- remote := s.GetCheckpointRemote()
- if remote == nil {
- t.Fatal("expected checkpoint_remote to be set")
- return
- }
- if remote.Provider != "github" || remote.Repo != "ashtom/zeugs-checkpoints" {
- t.Errorf("unexpected checkpoint_remote: %+v", remote)
- }
-}
-
-func TestConfigureCmd_CheckpointRemote_WritesToLocalFile(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--local", "--checkpoint-remote", "github:org/repo"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --local --checkpoint-remote failed: %v", err)
- }
-
- if !strings.Contains(stdout.String(), "settings.local.json") {
- t.Errorf("expected output to reference settings.local.json, got: %s", stdout.String())
- }
-
- // Verify the setting was written to settings.local.json, not settings.json
- localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
- if err != nil {
- t.Fatalf("failed to load local settings: %v", err)
- }
- remote := localS.GetCheckpointRemote()
- if remote == nil {
- t.Fatal("expected checkpoint_remote in local settings")
- }
-
- // Project settings should be unchanged
- projectS, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load project settings: %v", err)
- }
- if projectS.GetCheckpointRemote() != nil {
- t.Error("checkpoint_remote should not leak into project settings")
- }
-}
-
-func TestConfigureCmd_CheckpointRemote_LocalOnlyRepo(t *testing.T) {
- setupTestRepo(t)
- // Only local settings exist — no settings.json
- writeLocalSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--checkpoint-remote", "github:org/repo"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --checkpoint-remote on local-only repo failed: %v", err)
- }
-
- // Should NOT create settings.json
- if _, err := os.Stat(TraceSettingsFile); err == nil {
- t.Error("settings.json should not be created in a local-only repo")
- }
-
- // Should write to settings.local.json
- localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
- if err != nil {
- t.Fatalf("failed to load local settings: %v", err)
- }
- if localS.GetCheckpointRemote() == nil {
- t.Error("expected checkpoint_remote in local settings")
- }
-}
-
-func TestConfigureCmd_CheckpointRemote_InvalidFormat(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--checkpoint-remote", "invalid-format"})
-
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error for invalid --checkpoint-remote format")
- }
-}
-
-func TestConfigureCmd_CheckpointRemote_DoesNotLeakMergedSettings(t *testing.T) {
- setupTestRepo(t)
- // Project has enabled=true, local has log_level override
- writeSettings(t, testSettingsEnabled)
- writeLocalSettings(t, `{"log_level": "debug"}`)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--project", "--checkpoint-remote", "github:org/repo"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --project --checkpoint-remote failed: %v", err)
- }
-
- // Project settings should NOT contain log_level from local
- data, err := os.ReadFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to read settings: %v", err)
- }
- var raw map[string]json.RawMessage
- if err := json.Unmarshal(data, &raw); err != nil {
- t.Fatalf("failed to parse settings: %v", err)
- }
- if _, exists := raw["log_level"]; exists {
- t.Error("log_level from local settings leaked into project settings")
- }
-}
-
-func stubCLIAvailable(t *testing.T) {
- t.Helper()
- orig := isSummaryCLIAvailable
- isSummaryCLIAvailable = func(types.AgentName) bool { return true }
- t.Cleanup(func() { isSummaryCLIAvailable = orig })
-}
-
-func TestConfigureCmd_SummarizeProvider_UpdatesProjectSettings(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- stubCLIAvailable(t)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-provider", "codex", "--summarize-model", "gpt-5"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --summarize-provider failed: %v", err)
- }
-
- if !strings.Contains(stdout.String(), "Settings updated") {
- t.Errorf("expected 'Settings updated' output, got: %s", stdout.String())
- }
-
- s, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if s.SummaryGeneration == nil {
- t.Fatal("expected summary_generation to be set")
- }
- if s.SummaryGeneration.Provider != "codex" {
- t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex")
- }
- if s.SummaryGeneration.Model != "gpt-5" {
- t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "gpt-5")
- }
-}
-
-func TestConfigureCmd_SummarizeProvider_WritesToLocalFile(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- stubCLIAvailable(t)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--local", "--summarize-provider", "claude-code", "--summarize-model", "sonnet"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --local --summarize-provider failed: %v", err)
- }
-
- if !strings.Contains(stdout.String(), "settings.local.json") {
- t.Errorf("expected output to reference settings.local.json, got: %s", stdout.String())
- }
-
- localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
- if err != nil {
- t.Fatalf("failed to load local settings: %v", err)
- }
- if localS.SummaryGeneration == nil {
- t.Fatal("expected local summary_generation to be set")
- }
- if localS.SummaryGeneration.Provider != "claude-code" {
- t.Fatalf("local summary provider = %q, want %q", localS.SummaryGeneration.Provider, "claude-code")
- }
-
- projectS, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load project settings: %v", err)
- }
- if projectS.SummaryGeneration != nil {
- t.Fatal("summary_generation should not leak into project settings")
- }
-}
-
-func TestConfigureCmd_SummarizeProvider_ExternalEnablesExternalAgents(t *testing.T) {
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- const provider = "external-summary-config"
- externalDir := t.TempDir()
- writeExternalSummaryAgentBinary(t, externalDir, provider)
- t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-provider", provider})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --summarize-provider external failed: %v", err)
- }
-
- s, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if s.SummaryGeneration == nil {
- t.Fatal("expected summary_generation to be set")
- }
- if s.SummaryGeneration.Provider != provider {
- t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, provider)
- }
- if !s.ExternalAgents {
- t.Fatal("external summary provider should enable external_agents")
- }
- if !strings.Contains(stdout.String(), externalAgentsAutoEnabledNotice) {
- t.Fatalf("expected notice surfacing the external_agents flip, got stdout:\n%s", stdout.String())
- }
-}
-
-func TestConfigureCmd_SummarizeProvider_ExternalAlreadyEnabled_NoNotice(t *testing.T) {
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- setupTestRepo(t)
- writeSettings(t, `{"enabled": true, "external_agents": true}`)
-
- const provider = "external-summary-already-on"
- externalDir := t.TempDir()
- writeExternalSummaryAgentBinary(t, externalDir, provider)
- t.Setenv("PATH", externalDir+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-provider", provider})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --summarize-provider external failed: %v", err)
- }
-
- if strings.Contains(stdout.String(), externalAgentsAutoEnabledNotice) {
- t.Fatalf("notice should not fire when external_agents was already enabled, got stdout:\n%s", stdout.String())
- }
-}
-
-func TestConfigureCmd_SummarizeProvider_InvalidProvider(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-provider", "opencode"})
-
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error for unsupported summary provider")
- }
-}
-
-func TestConfigureCmd_SummarizeProvider_SwitchClearsStaleModel(t *testing.T) {
- stubCLIAvailable(t)
- setupTestRepo(t)
- writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code", "model": "sonnet"}}`)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-provider", "codex"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --summarize-provider codex failed: %v", err)
- }
-
- s, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if s.SummaryGeneration == nil {
- t.Fatal("expected summary_generation to be set")
- }
- if s.SummaryGeneration.Provider != "codex" {
- t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex")
- }
- if s.SummaryGeneration.Model != "" {
- t.Fatalf("summary model = %q, want empty after provider switch", s.SummaryGeneration.Model)
- }
-}
-
-func TestConfigureCmd_SummarizeModel_RequiresProvider(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-model", "sonnet"})
-
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected error for summarize-model without provider")
- }
-}
-
-func TestConfigureCmd_SummarizeModel_LocalInheritsProviderFromProject(t *testing.T) {
- setupTestRepo(t)
- stubCLIAvailable(t)
- // Project settings define the provider; local override only sets the model.
- writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code"}}`)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--local", "--summarize-model", "sonnet"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --local --summarize-model failed: %v", err)
- }
-
- localS, err := settings.LoadFromFile(TraceSettingsLocalFile)
- if err != nil {
- t.Fatalf("failed to load local settings: %v", err)
- }
- if localS.SummaryGeneration == nil {
- t.Fatal("expected local summary_generation to be set")
- }
- if localS.SummaryGeneration.Model != "sonnet" {
- t.Fatalf("local summary model = %q, want %q", localS.SummaryGeneration.Model, "sonnet")
- }
-
- // Project settings must not be modified.
- projectS, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load project settings: %v", err)
- }
- if projectS.SummaryGeneration.Model != "" {
- t.Fatalf("project model = %q, should remain empty", projectS.SummaryGeneration.Model)
- }
-}
-
-func TestConfigureCmd_SummarizeModel_UsesExistingProvider(t *testing.T) {
- setupTestRepo(t)
- stubCLIAvailable(t)
- writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code"}}`)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--summarize-model", "sonnet"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --summarize-model failed: %v", err)
- }
-
- s, err := settings.LoadFromFile(TraceSettingsFile)
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if s.SummaryGeneration == nil {
- t.Fatal("expected summary_generation to be set")
- }
- if s.SummaryGeneration.Provider != "claude-code" {
- t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "claude-code")
- }
- if s.SummaryGeneration.Model != "sonnet" {
- t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "sonnet")
- }
-}
-
-func TestSelectAllAgents_ReturnsAll(t *testing.T) {
- t.Parallel()
- available := []string{"claude-code", "gemini-cli", "opencode"}
- selected, err := selectAllAgents(available)
- if err != nil {
- t.Fatalf("selectAllAgents() error = %v", err)
- }
- if !slices.Equal(selected, available) {
- t.Errorf("selectAllAgents() = %v, want %v", selected, available)
- }
-}
-
-func TestSelectAllAgents_EmptyReturnsError(t *testing.T) {
- t.Parallel()
- _, err := selectAllAgents(nil)
- if err == nil {
- t.Fatal("expected error for empty input")
- }
-}
-
-func TestDetectOrSelectAgent_YesSelectsAll(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- var buf bytes.Buffer
- agents, err := detectOrSelectAgent(context.Background(), &buf, selectAllAgents)
- if err != nil {
- t.Fatalf("detectOrSelectAgent() with selectAllAgents error = %v", err)
- }
-
- // Should return at least 2 agents (claude-code + gemini-cli are registered in test imports)
- if len(agents) < 2 {
- t.Errorf("expected at least 2 agents with selectAllAgents, got %d", len(agents))
- }
-
- output := buf.String()
- if !strings.Contains(output, "Selected agents:") {
- t.Errorf("Expected output to contain 'Selected agents:', got: %s", output)
- }
-}
-
-func TestManageAgents_YesWorksNonInteractive(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
-
- // Install claude-code hooks so there's something installed
- writeClaudeHooksFixture(t)
-
- // Use a selectFn that only picks built-in agents to avoid failures
- // from stale external agent binaries registered by other tests.
- selectBuiltIn := func(available []string) ([]string, error) {
- var selected []string
- for _, name := range available {
- ag, err := agent.Get(types.AgentName(name))
- if err != nil {
- continue
- }
- if isBuiltInAgent(ag) {
- selected = append(selected, name)
- }
- }
- if len(selected) == 0 {
- return nil, errors.New("no built-in agents available")
- }
- return selected, nil
- }
-
- var buf bytes.Buffer
- err := runManageAgents(context.Background(), &buf, EnableOptions{}, selectBuiltIn)
- if err != nil {
- t.Fatalf("runManageAgents() with selectFn in non-interactive mode error = %v", err)
- }
-
- output := buf.String()
- // Should NOT print the non-interactive bail-out message
- if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
- t.Error("selectFn should bypass the interactivity check, but got non-interactive message")
- }
-}
-
-func TestEnableYes_TelemetryRespectsOptOut(t *testing.T) {
- // Cannot use t.Parallel() because subtests use t.Setenv
-
- t.Run("yes with telemetry=false", func(t *testing.T) {
- s := &TraceSettings{}
- opts := EnableOptions{Yes: true, Telemetry: false}
- if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- s.Telemetry = &f
- } else if s.Telemetry == nil {
- tr := true
- s.Telemetry = &tr
- }
- if s.Telemetry == nil || *s.Telemetry != false {
- t.Errorf("expected telemetry=false when --yes --telemetry=false, got %v", s.Telemetry)
- }
- })
-
- t.Run("yes with TRACE_TELEMETRY_OPTOUT", func(t *testing.T) {
- t.Setenv("TRACE_TELEMETRY_OPTOUT", "1")
- s := &TraceSettings{}
- opts := EnableOptions{Yes: true, Telemetry: true}
- if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- s.Telemetry = &f
- } else if s.Telemetry == nil {
- tr := true
- s.Telemetry = &tr
- }
- if s.Telemetry == nil || *s.Telemetry != false {
- t.Errorf("expected telemetry=false with TRACE_TELEMETRY_OPTOUT, got %v", s.Telemetry)
- }
- })
-
- t.Run("yes defaults to telemetry enabled", func(t *testing.T) {
- s := &TraceSettings{}
- opts := EnableOptions{Yes: true, Telemetry: true}
- if !opts.Telemetry {
- f := false
- s.Telemetry = &f
- } else if s.Telemetry == nil {
- tr := true
- s.Telemetry = &tr
- }
- if s.Telemetry == nil || *s.Telemetry != true {
- t.Errorf("expected telemetry=true with --yes (default), got %v", s.Telemetry)
- }
- })
-
- t.Run("yes preserves existing telemetry setting", func(t *testing.T) {
- existing := false
- s := &TraceSettings{Telemetry: &existing}
- opts := EnableOptions{Yes: true, Telemetry: true}
- if !opts.Telemetry || os.Getenv("TRACE_TELEMETRY_OPTOUT") != "" {
- f := false
- s.Telemetry = &f
- } else if s.Telemetry == nil {
- tr := true
- s.Telemetry = &tr
- }
- if *s.Telemetry != false {
- t.Errorf("expected existing telemetry=false to be preserved, got %v", *s.Telemetry)
- }
- })
-}
-
-func TestEnableCmd_YesFreshRepo_SkipsPromptsAndEnables(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- testutil.WriteFile(t, ".", "f.txt", "init")
- testutil.GitAdd(t, ".", "f.txt")
- testutil.GitCommit(t, ".", "init")
-
- // Use --yes with --agent to test the realistic CI scenario.
- // The --yes flag skips telemetry/Vercel prompts while --agent selects a specific agent.
- // The pure --yes-selects-all-agents path is covered by TestDetectOrSelectAgent_YesSelectsAll.
- cmd := newEnableCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--yes", "--agent", "claude-code"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable --yes --agent claude-code error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Ready.") {
- t.Errorf("expected 'Ready.' in output, got: %s", output)
- }
-
- // Verify settings were saved with telemetry enabled (--yes default)
- s, err := LoadTraceSettings(context.Background())
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if !s.Enabled {
- t.Error("expected enabled=true")
- }
-}
-
-func TestEnableCmd_YesWithAgent_AgentTakesPrecedence(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- testutil.WriteFile(t, ".", "f.txt", "init")
- testutil.GitAdd(t, ".", "f.txt")
- testutil.GitCommit(t, ".", "init")
-
- cmd := newEnableCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--yes", "--agent", "claude-code"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable --yes --agent claude-code error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
- }
-
- output := stdout.String()
- // --agent takes precedence — should show single-agent non-interactive output
- if !strings.Contains(output, "Agent: Claude Code") {
- t.Errorf("expected 'Agent: Claude Code' in output, got: %s", output)
- }
- // Should NOT have shown multi-select output
- if strings.Contains(output, "Selected agents:") {
- t.Errorf("--agent should bypass multi-select, but got 'Selected agents:' in: %s", output)
- }
-}
-
-func TestEnableCmd_YesOnConfiguredRepo_ManagesAgents(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- cmd := newEnableCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--yes"})
-
- // May partially fail due to stale external agents in global registry,
- // but the key behavior is that it doesn't bail out with the non-interactive message.
- _ = cmd.Execute() //nolint:errcheck // partial failure from stale test agents is expected
-
- output := stdout.String()
- // Should NOT bail out with non-interactive message
- if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
- t.Error("--yes should bypass non-interactive check, but got bail-out message")
- }
-}
-
-func TestEnableCmd_YesWithTelemetryFalse(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir and t.Setenv
- setupTestRepo(t)
- testutil.WriteFile(t, ".", "f.txt", "init")
- testutil.GitAdd(t, ".", "f.txt")
- testutil.GitCommit(t, ".", "init")
-
- cmd := newEnableCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--yes", "--agent", "claude-code", "--telemetry=false"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("enable --yes --telemetry=false error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
- }
-
- // Verify telemetry was disabled despite --yes
- s, err := LoadTraceSettings(context.Background())
- if err != nil {
- t.Fatalf("failed to load settings: %v", err)
- }
- if s.Telemetry == nil || *s.Telemetry != false {
- t.Errorf("expected telemetry=false when --yes --telemetry=false, got %v", s.Telemetry)
- }
-}
-
-func TestConfigureCmd_BarePrintsHelpHint(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- cmd := newSetupCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "trace agent") {
- t.Errorf("expected hint about 'trace agent' in help output, got: %s", output)
- }
- // Bare configure must not run the agent picker.
- if strings.Contains(output, "Cannot show agent selection in non-interactive mode") {
- t.Errorf("bare configure should not invoke agent picker, got: %s", output)
- }
-}
-
-func TestConfigureCmd_AgentFlagRemoved(t *testing.T) {
- t.Parallel()
- cmd := newSetupCmd()
- if cmd.Flags().Lookup("agent") != nil {
- t.Error("'configure' must not expose --agent (use 'trace agent add')")
- }
- if cmd.Flags().Lookup("remove") != nil {
- t.Error("'configure' must not expose --remove (use 'trace agent remove')")
- }
- if cmd.Flags().Lookup("yes") != nil {
- t.Error("'configure' must not expose --yes (lives on 'trace enable')")
- }
-}
-
-func TestConfigureCmd_TelemetryFlag_PersistsSetting(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- cmd.SetOut(&bytes.Buffer{})
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--telemetry=false"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --telemetry=false error = %v", err)
- }
-
- s, err := LoadTraceSettings(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if s.Telemetry == nil || *s.Telemetry != false {
- t.Errorf("expected telemetry=false, got %v", s.Telemetry)
- }
-}
-
-func TestConfigureCmd_AbsoluteGitHookPathFlag_PersistsAndReinstallsHook(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--absolute-git-hook-path"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --absolute-git-hook-path error = %v", err)
- }
-
- s, err := LoadTraceSettings(context.Background())
- if err != nil {
- t.Fatalf("load settings: %v", err)
- }
- if !s.AbsoluteGitHookPath {
- t.Error("expected absolute_git_hook_path=true after configure --absolute-git-hook-path")
- }
- if !strings.Contains(stdout.String(), "Reinstalled git hook") {
- t.Errorf("expected hook reinstall message, got: %s", stdout.String())
- }
-}
-
-func TestConfigureCmd_TelemetryAlone_DoesNotReinstallHook(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- cmd := newSetupCmd()
- var stdout bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"--telemetry=false"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatalf("configure --telemetry=false error = %v", err)
- }
-
- if strings.Contains(stdout.String(), "Reinstalled git hook") {
- t.Errorf("--telemetry alone should not trigger hook reinstall, got: %s", stdout.String())
- }
-}
-
-func TestConfigureCmd_FreshRepo_PointsAtEnable(t *testing.T) {
- // Cannot use t.Parallel() because we use t.Chdir
- setupTestRepo(t)
- // No settings written — fresh repo.
-
- cmd := newSetupCmd()
- var stdout, stderr bytes.Buffer
- cmd.SetOut(&stdout)
- cmd.SetErr(&stderr)
- cmd.SetArgs([]string{"--telemetry=false"})
-
- err := cmd.Execute()
- if err == nil {
- t.Fatal("expected configure on fresh repo to fail")
- }
- if !strings.Contains(stderr.String(), "trace enable") {
- t.Errorf("expected hint pointing at 'trace enable', got stderr: %s", stderr.String())
- }
-}
diff --git a/cli/status_2_test.go b/cli/status_2_test.go
new file mode 100644
index 0000000..17131a4
--- /dev/null
+++ b/cli/status_2_test.go
@@ -0,0 +1,806 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/lipgloss/v2"
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/session"
+)
+
+func TestTotalTokens(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil", func(t *testing.T) {
+ t.Parallel()
+ if got := totalTokens(nil); got != 0 {
+ t.Errorf("totalTokens(nil) = %d, want 0", got)
+ }
+ })
+
+ t.Run("basic", func(t *testing.T) {
+ t.Parallel()
+ tu := &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ }
+ if got := totalTokens(tu); got != 150 {
+ t.Errorf("totalTokens() = %d, want 150", got)
+ }
+ })
+
+ t.Run("with subagents", func(t *testing.T) {
+ t.Parallel()
+ tu := &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ SubagentTokens: &agent.TokenUsage{
+ InputTokens: 200,
+ OutputTokens: 100,
+ },
+ }
+ if got := totalTokens(tu); got != 450 {
+ t.Errorf("totalTokens() = %d, want 450", got)
+ }
+ })
+
+ t.Run("all fields", func(t *testing.T) {
+ t.Parallel()
+ tu := &agent.TokenUsage{
+ InputTokens: 100,
+ CacheCreationTokens: 50,
+ CacheReadTokens: 25,
+ OutputTokens: 75,
+ }
+ if got := totalTokens(tu); got != 250 {
+ t.Errorf("totalTokens() = %d, want 250", got)
+ }
+ })
+}
+
+func TestTotalTokens_ExcludesAPICallCount(t *testing.T) {
+ t.Parallel()
+
+ // APICallCount should NOT be included in token totals — it's a separate metric
+ tu := &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ APICallCount: 999, // should be ignored
+ }
+ got := totalTokens(tu)
+ if got != 150 {
+ t.Errorf("totalTokens() = %d, want 150 (APICallCount should be excluded)", got)
+ }
+}
+
+func TestTotalTokens_DeepSubagentNesting(t *testing.T) {
+ t.Parallel()
+
+ tu := &agent.TokenUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ SubagentTokens: &agent.TokenUsage{
+ InputTokens: 200,
+ OutputTokens: 100,
+ SubagentTokens: &agent.TokenUsage{
+ InputTokens: 50,
+ OutputTokens: 25,
+ },
+ },
+ }
+ // 100+50 + 200+100 + 50+25 = 525
+ if got := totalTokens(tu); got != 525 {
+ t.Errorf("totalTokens() = %d, want 525 (deep nesting)", got)
+ }
+}
+
+func TestActiveTimeDisplay(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil", func(t *testing.T) {
+ t.Parallel()
+ if got := activeTimeDisplay(nil); got != "" {
+ t.Errorf("activeTimeDisplay(nil) = %q, want empty", got)
+ }
+ })
+
+ t.Run("recent", func(t *testing.T) {
+ t.Parallel()
+ now := time.Now()
+ if got := activeTimeDisplay(&now); got != "active now" {
+ t.Errorf("activeTimeDisplay(now) = %q, want 'active now'", got)
+ }
+ })
+
+ t.Run("older", func(t *testing.T) {
+ t.Parallel()
+ older := time.Now().Add(-5 * time.Minute)
+ got := activeTimeDisplay(&older)
+ if got != "active 5m ago" {
+ t.Errorf("activeTimeDisplay(-5m) = %q, want 'active 5m ago'", got)
+ }
+ })
+}
+
+func TestShouldUseColor_NonTTY(t *testing.T) {
+ t.Parallel()
+
+ // bytes.Buffer is not a terminal → should return false
+ var buf bytes.Buffer
+ if shouldUseColor(&buf) {
+ t.Error("shouldUseColor(bytes.Buffer) should be false")
+ }
+}
+
+func TestShouldUseColor_NoColorEnv(t *testing.T) {
+ // NO_COLOR env var should force color off even for a real file
+ t.Setenv("NO_COLOR", "1")
+
+ f, err := os.CreateTemp(t.TempDir(), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+
+ if shouldUseColor(f) {
+ t.Error("shouldUseColor should be false when NO_COLOR is set")
+ }
+}
+
+func TestShouldUseColor_RegularFile(t *testing.T) {
+ t.Parallel()
+
+ // A regular file (not a terminal) should return false
+ f, err := os.CreateTemp(t.TempDir(), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+
+ if shouldUseColor(f) {
+ t.Error("shouldUseColor(regular file) should be false")
+ }
+}
+
+func TestNewStatusStyles_NonTTY(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+
+ if sty.colorEnabled {
+ t.Error("newStatusStyles(bytes.Buffer) should have colorEnabled=false")
+ }
+}
+
+func TestRender_ColorDisabled(t *testing.T) {
+ t.Parallel()
+
+ // When color is disabled, render should return text unchanged
+ sty := statusStyles{colorEnabled: false}
+ style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2"))
+
+ got := sty.render(style, "hello")
+ if got != "hello" {
+ t.Errorf("render with color disabled = %q, want %q", got, "hello")
+ }
+}
+
+func TestRender_ColorEnabled_CallsStyleRender(t *testing.T) {
+ t.Parallel()
+
+ // When colorEnabled=true, render should call style.Render (not return plain text).
+ // Note: lipgloss may strip ANSI in test environments without a terminal, so we
+ // can't assert ANSI codes. Instead, verify the code path is exercised and
+ // the text content is preserved.
+ sty := statusStyles{
+ colorEnabled: true,
+ bold: lipgloss.NewStyle().Bold(true),
+ }
+
+ got := sty.render(sty.bold, "hello")
+ if !strings.Contains(got, "hello") {
+ t.Errorf("render with color enabled should preserve text content, got: %q", got)
+ }
+}
+
+func TestRender_ColorToggle(t *testing.T) {
+ t.Parallel()
+
+ style := lipgloss.NewStyle().Bold(true)
+
+ // Color disabled: must return exact input
+ styOff := statusStyles{colorEnabled: false}
+ got := styOff.render(style, "test")
+ if got != "test" {
+ t.Errorf("render(colorEnabled=false) = %q, want exact %q", got, "test")
+ }
+
+ // Color enabled: exercises style.Render code path, text preserved
+ styOn := statusStyles{colorEnabled: true}
+ got = styOn.render(style, "test")
+ if !strings.Contains(got, "test") {
+ t.Errorf("render(colorEnabled=true) should contain 'test', got: %q", got)
+ }
+}
+
+func TestSectionRule_PlainText(t *testing.T) {
+ t.Parallel()
+
+ sty := statusStyles{colorEnabled: false, width: 40}
+ rule := sty.sectionRule("Active Sessions", 40)
+
+ // Plain text should contain the label
+ if !strings.Contains(rule, "Active Sessions") {
+ t.Errorf("sectionRule should contain label, got: %q", rule)
+ }
+ if !strings.Contains(rule, "─") {
+ t.Errorf("sectionRule should contain rule characters, got: %q", rule)
+ }
+ // With color disabled, should have no ANSI escapes
+ if strings.Contains(rule, "\x1b[") {
+ t.Errorf("sectionRule with color disabled should have no ANSI escapes, got: %q", rule)
+ }
+}
+
+func TestHorizontalRule_PlainText(t *testing.T) {
+ t.Parallel()
+
+ sty := statusStyles{colorEnabled: false}
+ rule := sty.horizontalRule(15)
+
+ // Should be no ANSI escapes
+ if strings.Contains(rule, "\x1b[") {
+ t.Errorf("horizontalRule with color disabled should have no ANSI escapes, got: %q", rule)
+ }
+ if len([]rune(rule)) != 15 {
+ t.Errorf("horizontalRule(15) has %d runes, want 15", len([]rune(rule)))
+ }
+}
+
+func TestHorizontalRule(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+
+ rule := sty.horizontalRule(20)
+ if len([]rune(rule)) != 20 {
+ t.Errorf("horizontalRule(20) has %d runes, want 20", len([]rune(rule)))
+ }
+ // All characters should be the box-drawing dash
+ for _, r := range rule {
+ if r != '─' {
+ t.Errorf("horizontalRule contains unexpected rune %q", r)
+ break
+ }
+ }
+}
+
+func TestGetTerminalWidth_NonTTY(t *testing.T) {
+ t.Parallel()
+
+ // A bytes.Buffer is not a terminal — should fall back to 60
+ var buf bytes.Buffer
+ width := getTerminalWidth(&buf)
+ // In CI/test environments without a real terminal on Stdout/Stderr,
+ // the fallback should be 60. If running in a terminal, it may be
+ // capped at 80. Either is acceptable.
+ if width != 60 && width > 80 {
+ t.Errorf("getTerminalWidth(bytes.Buffer) = %d, want 60 or ≤80", width)
+ }
+}
+
+func TestGetTerminalWidth_RegularFile(t *testing.T) {
+ t.Parallel()
+
+ // A regular file (not a terminal) should not report a terminal width
+ f, err := os.CreateTemp(t.TempDir(), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+
+ width := getTerminalWidth(f)
+ // Regular file fd won't have a terminal size, so it should fall back
+ if width != 60 && width > 80 {
+ t.Errorf("getTerminalWidth(regular file) = %d, want 60 or ≤80", width)
+ }
+}
+
+func TestNewStatusStyles_Width(t *testing.T) {
+ t.Parallel()
+
+ // For a non-terminal writer, width should be the fallback (60)
+ // unless Stdout/Stderr happen to be terminals
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+
+ if sty.width == 0 {
+ t.Error("newStatusStyles should set a non-zero width")
+ }
+ if sty.width > 80 {
+ t.Errorf("newStatusStyles width = %d, should be capped at 80", sty.width)
+ }
+}
+
+func TestSectionRule_NarrowWidth(t *testing.T) {
+ t.Parallel()
+
+ // When width is very small (smaller than prefix + label), trailing should be at least 1
+ sty := statusStyles{colorEnabled: false, width: 10}
+ rule := sty.sectionRule("Active Sessions", 10)
+
+ // Should still contain the label and at least one trailing dash
+ if !strings.Contains(rule, "Active Sessions") {
+ t.Errorf("sectionRule with narrow width should still contain label, got: %q", rule)
+ }
+ if !strings.Contains(rule, "─") {
+ t.Errorf("sectionRule with narrow width should have at least one trailing dash, got: %q", rule)
+ }
+}
+
+func TestActiveTimeDisplay_Hours(t *testing.T) {
+ t.Parallel()
+
+ hoursAgo := time.Now().Add(-3 * time.Hour)
+ got := activeTimeDisplay(&hoursAgo)
+ if got != "active 3h ago" {
+ t.Errorf("activeTimeDisplay(-3h) = %q, want 'active 3h ago'", got)
+ }
+}
+
+func TestActiveTimeDisplay_Days(t *testing.T) {
+ t.Parallel()
+
+ daysAgo := time.Now().Add(-48 * time.Hour)
+ got := activeTimeDisplay(&daysAgo)
+ if got != "active 2d ago" {
+ t.Errorf("activeTimeDisplay(-48h) = %q, want 'active 2d ago'", got)
+ }
+}
+
+func TestFormatSettingsStatusShort_Enabled(t *testing.T) {
+ setupTestRepo(t)
+
+ sty := statusStyles{colorEnabled: false, width: 60}
+ s := &TraceSettings{
+ Enabled: true,
+ Strategy: "manual-commit",
+ }
+
+ result := formatSettingsStatusShort(context.Background(), s, sty)
+
+ if !strings.Contains(result, "●") {
+ t.Errorf("Enabled status should have green dot, got: %q", result)
+ }
+ if !strings.Contains(result, "Enabled") {
+ t.Errorf("Expected 'Enabled' in output, got: %q", result)
+ }
+ if !strings.Contains(result, "manual-commit") {
+ t.Errorf("Expected strategy in output, got: %q", result)
+ }
+}
+
+func TestFormatSettingsStatusShort_Disabled(t *testing.T) {
+ setupTestRepo(t)
+
+ sty := statusStyles{colorEnabled: false, width: 60}
+ s := &TraceSettings{
+ Enabled: false,
+ Strategy: "manual-commit",
+ }
+
+ result := formatSettingsStatusShort(context.Background(), s, sty)
+
+ if !strings.Contains(result, "○") {
+ t.Errorf("Disabled status should have open dot, got: %q", result)
+ }
+ if !strings.Contains(result, "Disabled") {
+ t.Errorf("Expected 'Disabled' in output, got: %q", result)
+ }
+ if !strings.Contains(result, "manual-commit") {
+ t.Errorf("Expected strategy in output, got: %q", result)
+ }
+}
+
+func TestRunStatus_ShowsEnabledAgents(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, false); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Agents ·") {
+ t.Errorf("Expected 'Agents ·' in output, got: %s", output)
+ }
+ if !strings.Contains(output, "Claude Code") {
+ t.Errorf("Expected 'Claude Code' in output, got: %s", output)
+ }
+}
+
+func TestRunStatus_EnabledNoAgentsHidesHooksLine(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ // No agent hooks installed
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, false); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ output := stdout.String()
+ if strings.Contains(output, "Agents ·") {
+ t.Errorf("Should not show hooks line when no agents installed, got: %s", output)
+ }
+}
+
+func TestRunStatus_DetailedShowsEnabledAgents(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, true, false); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ output := stdout.String()
+ if !strings.Contains(output, "Agents ·") {
+ t.Errorf("Expected 'Agents ·' in detailed output, got: %s", output)
+ }
+ if !strings.Contains(output, "Claude Code") {
+ t.Errorf("Expected 'Claude Code' in detailed output, got: %s", output)
+ }
+}
+
+func TestWriteActiveSessions_OmitsTokensWhenNoTokenData(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+ recentInteraction := now.Add(-5 * time.Minute)
+
+ states := []*session.State{
+ {
+ SessionID: "no-token-session",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-30 * time.Minute),
+ LastInteractionTime: &recentInteraction,
+ Phase: session.PhaseActive,
+ LastPrompt: "explain this code",
+ AgentType: "Claude Code",
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if strings.Contains(output, "tokens") {
+ t.Errorf("Session with no token data should NOT show tokens, got: %s", output)
+ }
+}
+
+func TestWriteActiveSessions_ShowsTokensWithCheckpoints(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+ recentInteraction := now.Add(-5 * time.Minute)
+
+ states := []*session.State{
+ {
+ SessionID: "has-checkpoint-session",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-30 * time.Minute),
+ LastInteractionTime: &recentInteraction,
+ Phase: session.PhaseActive,
+ LastPrompt: "fix the bug",
+ AgentType: "Claude Code",
+ StepCount: 2,
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 800,
+ OutputTokens: 400,
+ },
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if !strings.Contains(output, "tokens 1.2k") {
+ t.Errorf("Session with checkpoints should show tokens, got: %s", output)
+ }
+}
+
+func TestRunStatus_DetailedDisabledDoesNotShowAgents(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsDisabled)
+ writeClaudeHooksFixture(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, true, false); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ output := stdout.String()
+ if strings.Contains(output, "Agents ·") {
+ t.Errorf("Disabled detailed status should not show agents, got: %s", output)
+ }
+}
+
+func TestRunStatus_DisabledDoesNotShowAgents(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsDisabled)
+ writeClaudeHooksFixture(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, false); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ output := stdout.String()
+ if strings.Contains(output, "Agents ·") {
+ t.Errorf("Disabled status should not show agents, got: %s", output)
+ }
+}
+
+func TestFormatSettingsStatus_Project(t *testing.T) {
+ t.Parallel()
+
+ sty := statusStyles{colorEnabled: false, width: 60}
+ s := &TraceSettings{
+ Enabled: true,
+ Strategy: "manual-commit",
+ }
+
+ result := formatSettingsStatus("Project", s, sty)
+
+ if !strings.Contains(result, "Project") {
+ t.Errorf("Expected 'Project' prefix, got: %q", result)
+ }
+ if !strings.Contains(result, "enabled") {
+ t.Errorf("Expected 'enabled' in output, got: %q", result)
+ }
+ if !strings.Contains(result, "manual-commit") {
+ t.Errorf("Expected strategy in output, got: %q", result)
+ }
+}
+
+func TestFormatSettingsStatus_LocalDisabled(t *testing.T) {
+ t.Parallel()
+
+ sty := statusStyles{colorEnabled: false, width: 60}
+ s := &TraceSettings{
+ Enabled: false,
+ Strategy: "manual-commit",
+ }
+
+ result := formatSettingsStatus("Local", s, sty)
+
+ if !strings.Contains(result, "Local") {
+ t.Errorf("Expected 'Local' prefix, got: %q", result)
+ }
+ if !strings.Contains(result, "disabled") {
+ t.Errorf("Expected 'disabled' in output, got: %q", result)
+ }
+ if !strings.Contains(result, "manual-commit") {
+ t.Errorf("Expected strategy in output, got: %q", result)
+ }
+}
+
+func TestWriteActiveSessions_StaleIndicator(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+ staleInteraction := now.Add(-2 * time.Hour) // well past 1hr threshold
+
+ states := []*session.State{
+ {
+ SessionID: "stale-session-1",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-3 * time.Hour),
+ LastInteractionTime: &staleInteraction,
+ Phase: session.PhaseActive,
+ LastPrompt: "fix the bug",
+ AgentType: "Claude Code",
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if !strings.Contains(output, "stale") {
+ t.Errorf("Expected 'stale' indicator for session with interaction >1hr ago, got: %s", output)
+ }
+ if !strings.Contains(output, "trace doctor") {
+ t.Errorf("Expected 'trace doctor' hint in stale indicator, got: %s", output)
+ }
+}
+
+func TestIsStuckActiveSession(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now()
+ recent := now.Add(-5 * time.Minute)
+ stale := now.Add(-2 * time.Hour)
+ brandNew := now.Add(-10 * time.Second)
+
+ tests := []struct {
+ name string
+ state *session.State
+ want bool
+ }{
+ {
+ name: "active with stale interaction",
+ state: &session.State{Phase: session.PhaseActive, LastInteractionTime: &stale},
+ want: true,
+ },
+ {
+ name: "active with nil interaction and old start",
+ state: &session.State{Phase: session.PhaseActive, LastInteractionTime: nil, StartedAt: now.Add(-2 * time.Hour)},
+ want: true,
+ },
+ {
+ name: "active with nil interaction and recent start",
+ state: &session.State{Phase: session.PhaseActive, LastInteractionTime: nil, StartedAt: brandNew},
+ want: false,
+ },
+ {
+ name: "active with recent interaction",
+ state: &session.State{Phase: session.PhaseActive, LastInteractionTime: &recent},
+ want: false,
+ },
+ {
+ name: "idle with stale interaction",
+ state: &session.State{Phase: session.PhaseIdle, LastInteractionTime: &stale},
+ want: false,
+ },
+ {
+ name: "ended with stale interaction",
+ state: &session.State{Phase: session.PhaseEnded, LastInteractionTime: &stale},
+ want: false,
+ },
+ {
+ name: "empty phase with stale interaction",
+ state: &session.State{Phase: "", LastInteractionTime: &stale},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := tt.state.IsStuckActive(); got != tt.want {
+ t.Errorf("IsStuckActive() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestWriteActiveSessions_StaleWithNilInteractionOldStart(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+
+ states := []*session.State{
+ {
+ SessionID: "old-nil-interaction-session",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-2 * time.Hour),
+ LastInteractionTime: nil,
+ Phase: session.PhaseActive,
+ LastPrompt: "do something",
+ AgentType: "Claude Code",
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if !strings.Contains(output, "stale") {
+ t.Errorf("Old session with nil LastInteractionTime should show stale indicator, got: %s", output)
+ }
+}
+
+func TestWriteActiveSessions_NotStaleWhenBrandNew(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+
+ states := []*session.State{
+ {
+ SessionID: "brand-new-session",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-10 * time.Second),
+ LastInteractionTime: nil,
+ Phase: session.PhaseActive,
+ LastPrompt: "hello",
+ AgentType: "Claude Code",
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if strings.Contains(output, "stale") {
+ t.Errorf("Brand-new session should NOT show stale indicator, got: %s", output)
+ }
+}
diff --git a/cli/status_3_test.go b/cli/status_3_test.go
new file mode 100644
index 0000000..07ee0e1
--- /dev/null
+++ b/cli/status_3_test.go
@@ -0,0 +1,275 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/session"
+)
+
+func TestWriteActiveSessions_NotStaleWhenRecent(t *testing.T) {
+ setupTestRepo(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+ recentInteraction := now.Add(-5 * time.Minute)
+
+ states := []*session.State{
+ {
+ SessionID: "fresh-session-1",
+ WorktreePath: "/Users/test/repo",
+ StartedAt: now.Add(-30 * time.Minute),
+ LastInteractionTime: &recentInteraction,
+ Phase: session.PhaseActive,
+ LastPrompt: "add feature",
+ AgentType: "Claude Code",
+ },
+ }
+
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ sty := newStatusStyles(&buf)
+ writeActiveSessions(context.Background(), &buf, sty)
+
+ output := buf.String()
+
+ if strings.Contains(output, "stale") {
+ t.Errorf("Session with recent interaction should NOT show stale indicator, got: %s", output)
+ }
+}
+
+func TestFormatSettingsStatus_Separators(t *testing.T) {
+ t.Parallel()
+
+ sty := statusStyles{colorEnabled: false, width: 60}
+ s := &TraceSettings{
+ Enabled: true,
+ Strategy: "manual-commit",
+ }
+
+ result := formatSettingsStatus("Project", s, sty)
+
+ // Should use · as separator (plain text, no ANSI)
+ if !strings.Contains(result, "·") {
+ t.Errorf("Expected '·' separators in output, got: %q", result)
+ }
+}
+
+func TestRunStatusJSON_Enabled(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if !result.Enabled {
+ t.Error("Expected enabled=true")
+ }
+ found := false
+ for _, a := range result.Agents {
+ if a == "Claude Code" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected agents to contain 'Claude Code', got %v", result.Agents)
+ }
+ if result.Error != "" {
+ t.Errorf("Expected no error, got %q", result.Error)
+ }
+}
+
+func TestRunStatusJSON_Disabled(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsDisabled)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if result.Enabled {
+ t.Error("Expected enabled=false")
+ }
+}
+
+func TestRunStatusJSON_NotSetUp(t *testing.T) {
+ setupTestRepo(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if result.Enabled {
+ t.Error("Expected enabled=false")
+ }
+ if result.Error != "not set up" {
+ t.Errorf("Expected error='not set up', got %q", result.Error)
+ }
+}
+
+func TestRunStatusJSON_NotGitRepo(t *testing.T) {
+ setupTestDir(t)
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if result.Enabled {
+ t.Error("Expected enabled=false")
+ }
+ if result.Error != "not a git repository" {
+ t.Errorf("Expected error='not a git repository', got %q", result.Error)
+ }
+}
+
+func TestRunStatusJSON_WithActiveSessions(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+ writeClaudeHooksFixture(t)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ state := &session.State{
+ SessionID: "test-json-session",
+ WorktreePath: "/test/repo",
+ StartedAt: time.Now(),
+ Phase: session.PhaseActive,
+ AgentType: "Claude Code",
+ ModelName: "sonnet-4.1",
+ }
+ if err := store.Save(context.Background(), state); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if len(result.ActiveSessions) != 1 {
+ t.Fatalf("Expected 1 active session, got %d", len(result.ActiveSessions))
+ }
+ s := result.ActiveSessions[0]
+ if s.Agent != "Claude Code" {
+ t.Errorf("Expected agent='Claude Code', got %q", s.Agent)
+ }
+ if s.Model != "sonnet-4.1" {
+ t.Errorf("Expected model='sonnet-4.1', got %q", s.Model)
+ }
+ if s.Status != "active" {
+ t.Errorf("Expected status='active', got %q", s.Status)
+ }
+}
+
+func TestRunStatusJSON_DeduplicatesSessions(t *testing.T) {
+ setupTestRepo(t)
+ writeSettings(t, testSettingsEnabled)
+
+ store, err := session.NewStateStore(context.Background())
+ if err != nil {
+ t.Fatalf("NewStateStore() error = %v", err)
+ }
+
+ now := time.Now()
+ states := []*session.State{
+ {
+ SessionID: "codex-idle-1",
+ WorktreePath: "/test/repo",
+ StartedAt: now.Add(-30 * time.Minute),
+ Phase: session.PhaseIdle,
+ AgentType: "Codex",
+ },
+ {
+ SessionID: "codex-idle-2",
+ WorktreePath: "/test/repo",
+ StartedAt: now.Add(-20 * time.Minute),
+ Phase: session.PhaseIdle,
+ AgentType: "Codex",
+ },
+ {
+ SessionID: "codex-active",
+ WorktreePath: "/test/repo",
+ StartedAt: now.Add(-5 * time.Minute),
+ Phase: session.PhaseActive,
+ AgentType: "Codex",
+ ModelName: "codex-mini",
+ },
+ }
+ for _, s := range states {
+ if err := store.Save(context.Background(), s); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ }
+
+ var stdout bytes.Buffer
+ if err := runStatus(context.Background(), &stdout, false, true); err != nil {
+ t.Fatalf("runStatus() error = %v", err)
+ }
+
+ var result statusJSON
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+
+ if len(result.ActiveSessions) != 1 {
+ t.Fatalf("Expected 1 deduplicated session, got %d", len(result.ActiveSessions))
+ }
+ s := result.ActiveSessions[0]
+ if s.Agent != "Codex" {
+ t.Errorf("Expected agent='Codex', got %q", s.Agent)
+ }
+ if s.Status != "active" {
+ t.Errorf("Expected status='active' (active wins over idle), got %q", s.Status)
+ }
+ if s.Model != "codex-mini" {
+ t.Errorf("Expected model='codex-mini' from active session, got %q", s.Model)
+ }
+}
diff --git a/cli/status_test.go b/cli/status_test.go
index de24e6f..5cf03b6 100644
--- a/cli/status_test.go
+++ b/cli/status_test.go
@@ -3,14 +3,12 @@ package cli
import (
"bytes"
"context"
- "encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
- "charm.land/lipgloss/v2"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/session"
@@ -803,1058 +801,3 @@ func TestFormatTokenCount(t *testing.T) {
})
}
}
-
-func TestTotalTokens(t *testing.T) {
- t.Parallel()
-
- t.Run("nil", func(t *testing.T) {
- t.Parallel()
- if got := totalTokens(nil); got != 0 {
- t.Errorf("totalTokens(nil) = %d, want 0", got)
- }
- })
-
- t.Run("basic", func(t *testing.T) {
- t.Parallel()
- tu := &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- }
- if got := totalTokens(tu); got != 150 {
- t.Errorf("totalTokens() = %d, want 150", got)
- }
- })
-
- t.Run("with subagents", func(t *testing.T) {
- t.Parallel()
- tu := &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- SubagentTokens: &agent.TokenUsage{
- InputTokens: 200,
- OutputTokens: 100,
- },
- }
- if got := totalTokens(tu); got != 450 {
- t.Errorf("totalTokens() = %d, want 450", got)
- }
- })
-
- t.Run("all fields", func(t *testing.T) {
- t.Parallel()
- tu := &agent.TokenUsage{
- InputTokens: 100,
- CacheCreationTokens: 50,
- CacheReadTokens: 25,
- OutputTokens: 75,
- }
- if got := totalTokens(tu); got != 250 {
- t.Errorf("totalTokens() = %d, want 250", got)
- }
- })
-}
-
-func TestTotalTokens_ExcludesAPICallCount(t *testing.T) {
- t.Parallel()
-
- // APICallCount should NOT be included in token totals — it's a separate metric
- tu := &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- APICallCount: 999, // should be ignored
- }
- got := totalTokens(tu)
- if got != 150 {
- t.Errorf("totalTokens() = %d, want 150 (APICallCount should be excluded)", got)
- }
-}
-
-func TestTotalTokens_DeepSubagentNesting(t *testing.T) {
- t.Parallel()
-
- tu := &agent.TokenUsage{
- InputTokens: 100,
- OutputTokens: 50,
- SubagentTokens: &agent.TokenUsage{
- InputTokens: 200,
- OutputTokens: 100,
- SubagentTokens: &agent.TokenUsage{
- InputTokens: 50,
- OutputTokens: 25,
- },
- },
- }
- // 100+50 + 200+100 + 50+25 = 525
- if got := totalTokens(tu); got != 525 {
- t.Errorf("totalTokens() = %d, want 525 (deep nesting)", got)
- }
-}
-
-func TestActiveTimeDisplay(t *testing.T) {
- t.Parallel()
-
- t.Run("nil", func(t *testing.T) {
- t.Parallel()
- if got := activeTimeDisplay(nil); got != "" {
- t.Errorf("activeTimeDisplay(nil) = %q, want empty", got)
- }
- })
-
- t.Run("recent", func(t *testing.T) {
- t.Parallel()
- now := time.Now()
- if got := activeTimeDisplay(&now); got != "active now" {
- t.Errorf("activeTimeDisplay(now) = %q, want 'active now'", got)
- }
- })
-
- t.Run("older", func(t *testing.T) {
- t.Parallel()
- older := time.Now().Add(-5 * time.Minute)
- got := activeTimeDisplay(&older)
- if got != "active 5m ago" {
- t.Errorf("activeTimeDisplay(-5m) = %q, want 'active 5m ago'", got)
- }
- })
-}
-
-func TestShouldUseColor_NonTTY(t *testing.T) {
- t.Parallel()
-
- // bytes.Buffer is not a terminal → should return false
- var buf bytes.Buffer
- if shouldUseColor(&buf) {
- t.Error("shouldUseColor(bytes.Buffer) should be false")
- }
-}
-
-func TestShouldUseColor_NoColorEnv(t *testing.T) {
- // NO_COLOR env var should force color off even for a real file
- t.Setenv("NO_COLOR", "1")
-
- f, err := os.CreateTemp(t.TempDir(), "test")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
-
- if shouldUseColor(f) {
- t.Error("shouldUseColor should be false when NO_COLOR is set")
- }
-}
-
-func TestShouldUseColor_RegularFile(t *testing.T) {
- t.Parallel()
-
- // A regular file (not a terminal) should return false
- f, err := os.CreateTemp(t.TempDir(), "test")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
-
- if shouldUseColor(f) {
- t.Error("shouldUseColor(regular file) should be false")
- }
-}
-
-func TestNewStatusStyles_NonTTY(t *testing.T) {
- t.Parallel()
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
-
- if sty.colorEnabled {
- t.Error("newStatusStyles(bytes.Buffer) should have colorEnabled=false")
- }
-}
-
-func TestRender_ColorDisabled(t *testing.T) {
- t.Parallel()
-
- // When color is disabled, render should return text unchanged
- sty := statusStyles{colorEnabled: false}
- style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2"))
-
- got := sty.render(style, "hello")
- if got != "hello" {
- t.Errorf("render with color disabled = %q, want %q", got, "hello")
- }
-}
-
-func TestRender_ColorEnabled_CallsStyleRender(t *testing.T) {
- t.Parallel()
-
- // When colorEnabled=true, render should call style.Render (not return plain text).
- // Note: lipgloss may strip ANSI in test environments without a terminal, so we
- // can't assert ANSI codes. Instead, verify the code path is exercised and
- // the text content is preserved.
- sty := statusStyles{
- colorEnabled: true,
- bold: lipgloss.NewStyle().Bold(true),
- }
-
- got := sty.render(sty.bold, "hello")
- if !strings.Contains(got, "hello") {
- t.Errorf("render with color enabled should preserve text content, got: %q", got)
- }
-}
-
-func TestRender_ColorToggle(t *testing.T) {
- t.Parallel()
-
- style := lipgloss.NewStyle().Bold(true)
-
- // Color disabled: must return exact input
- styOff := statusStyles{colorEnabled: false}
- got := styOff.render(style, "test")
- if got != "test" {
- t.Errorf("render(colorEnabled=false) = %q, want exact %q", got, "test")
- }
-
- // Color enabled: exercises style.Render code path, text preserved
- styOn := statusStyles{colorEnabled: true}
- got = styOn.render(style, "test")
- if !strings.Contains(got, "test") {
- t.Errorf("render(colorEnabled=true) should contain 'test', got: %q", got)
- }
-}
-
-func TestSectionRule_PlainText(t *testing.T) {
- t.Parallel()
-
- sty := statusStyles{colorEnabled: false, width: 40}
- rule := sty.sectionRule("Active Sessions", 40)
-
- // Plain text should contain the label
- if !strings.Contains(rule, "Active Sessions") {
- t.Errorf("sectionRule should contain label, got: %q", rule)
- }
- if !strings.Contains(rule, "─") {
- t.Errorf("sectionRule should contain rule characters, got: %q", rule)
- }
- // With color disabled, should have no ANSI escapes
- if strings.Contains(rule, "\x1b[") {
- t.Errorf("sectionRule with color disabled should have no ANSI escapes, got: %q", rule)
- }
-}
-
-func TestHorizontalRule_PlainText(t *testing.T) {
- t.Parallel()
-
- sty := statusStyles{colorEnabled: false}
- rule := sty.horizontalRule(15)
-
- // Should be no ANSI escapes
- if strings.Contains(rule, "\x1b[") {
- t.Errorf("horizontalRule with color disabled should have no ANSI escapes, got: %q", rule)
- }
- if len([]rune(rule)) != 15 {
- t.Errorf("horizontalRule(15) has %d runes, want 15", len([]rune(rule)))
- }
-}
-
-func TestHorizontalRule(t *testing.T) {
- t.Parallel()
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
-
- rule := sty.horizontalRule(20)
- if len([]rune(rule)) != 20 {
- t.Errorf("horizontalRule(20) has %d runes, want 20", len([]rune(rule)))
- }
- // All characters should be the box-drawing dash
- for _, r := range rule {
- if r != '─' {
- t.Errorf("horizontalRule contains unexpected rune %q", r)
- break
- }
- }
-}
-
-func TestGetTerminalWidth_NonTTY(t *testing.T) {
- t.Parallel()
-
- // A bytes.Buffer is not a terminal — should fall back to 60
- var buf bytes.Buffer
- width := getTerminalWidth(&buf)
- // In CI/test environments without a real terminal on Stdout/Stderr,
- // the fallback should be 60. If running in a terminal, it may be
- // capped at 80. Either is acceptable.
- if width != 60 && width > 80 {
- t.Errorf("getTerminalWidth(bytes.Buffer) = %d, want 60 or ≤80", width)
- }
-}
-
-func TestGetTerminalWidth_RegularFile(t *testing.T) {
- t.Parallel()
-
- // A regular file (not a terminal) should not report a terminal width
- f, err := os.CreateTemp(t.TempDir(), "test")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
-
- width := getTerminalWidth(f)
- // Regular file fd won't have a terminal size, so it should fall back
- if width != 60 && width > 80 {
- t.Errorf("getTerminalWidth(regular file) = %d, want 60 or ≤80", width)
- }
-}
-
-func TestNewStatusStyles_Width(t *testing.T) {
- t.Parallel()
-
- // For a non-terminal writer, width should be the fallback (60)
- // unless Stdout/Stderr happen to be terminals
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
-
- if sty.width == 0 {
- t.Error("newStatusStyles should set a non-zero width")
- }
- if sty.width > 80 {
- t.Errorf("newStatusStyles width = %d, should be capped at 80", sty.width)
- }
-}
-
-func TestSectionRule_NarrowWidth(t *testing.T) {
- t.Parallel()
-
- // When width is very small (smaller than prefix + label), trailing should be at least 1
- sty := statusStyles{colorEnabled: false, width: 10}
- rule := sty.sectionRule("Active Sessions", 10)
-
- // Should still contain the label and at least one trailing dash
- if !strings.Contains(rule, "Active Sessions") {
- t.Errorf("sectionRule with narrow width should still contain label, got: %q", rule)
- }
- if !strings.Contains(rule, "─") {
- t.Errorf("sectionRule with narrow width should have at least one trailing dash, got: %q", rule)
- }
-}
-
-func TestActiveTimeDisplay_Hours(t *testing.T) {
- t.Parallel()
-
- hoursAgo := time.Now().Add(-3 * time.Hour)
- got := activeTimeDisplay(&hoursAgo)
- if got != "active 3h ago" {
- t.Errorf("activeTimeDisplay(-3h) = %q, want 'active 3h ago'", got)
- }
-}
-
-func TestActiveTimeDisplay_Days(t *testing.T) {
- t.Parallel()
-
- daysAgo := time.Now().Add(-48 * time.Hour)
- got := activeTimeDisplay(&daysAgo)
- if got != "active 2d ago" {
- t.Errorf("activeTimeDisplay(-48h) = %q, want 'active 2d ago'", got)
- }
-}
-
-func TestFormatSettingsStatusShort_Enabled(t *testing.T) {
- setupTestRepo(t)
-
- sty := statusStyles{colorEnabled: false, width: 60}
- s := &TraceSettings{
- Enabled: true,
- Strategy: "manual-commit",
- }
-
- result := formatSettingsStatusShort(context.Background(), s, sty)
-
- if !strings.Contains(result, "●") {
- t.Errorf("Enabled status should have green dot, got: %q", result)
- }
- if !strings.Contains(result, "Enabled") {
- t.Errorf("Expected 'Enabled' in output, got: %q", result)
- }
- if !strings.Contains(result, "manual-commit") {
- t.Errorf("Expected strategy in output, got: %q", result)
- }
-}
-
-func TestFormatSettingsStatusShort_Disabled(t *testing.T) {
- setupTestRepo(t)
-
- sty := statusStyles{colorEnabled: false, width: 60}
- s := &TraceSettings{
- Enabled: false,
- Strategy: "manual-commit",
- }
-
- result := formatSettingsStatusShort(context.Background(), s, sty)
-
- if !strings.Contains(result, "○") {
- t.Errorf("Disabled status should have open dot, got: %q", result)
- }
- if !strings.Contains(result, "Disabled") {
- t.Errorf("Expected 'Disabled' in output, got: %q", result)
- }
- if !strings.Contains(result, "manual-commit") {
- t.Errorf("Expected strategy in output, got: %q", result)
- }
-}
-
-func TestRunStatus_ShowsEnabledAgents(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, false); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Agents ·") {
- t.Errorf("Expected 'Agents ·' in output, got: %s", output)
- }
- if !strings.Contains(output, "Claude Code") {
- t.Errorf("Expected 'Claude Code' in output, got: %s", output)
- }
-}
-
-func TestRunStatus_EnabledNoAgentsHidesHooksLine(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- // No agent hooks installed
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, false); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- output := stdout.String()
- if strings.Contains(output, "Agents ·") {
- t.Errorf("Should not show hooks line when no agents installed, got: %s", output)
- }
-}
-
-func TestRunStatus_DetailedShowsEnabledAgents(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, true, false); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- output := stdout.String()
- if !strings.Contains(output, "Agents ·") {
- t.Errorf("Expected 'Agents ·' in detailed output, got: %s", output)
- }
- if !strings.Contains(output, "Claude Code") {
- t.Errorf("Expected 'Claude Code' in detailed output, got: %s", output)
- }
-}
-
-func TestWriteActiveSessions_OmitsTokensWhenNoTokenData(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
- recentInteraction := now.Add(-5 * time.Minute)
-
- states := []*session.State{
- {
- SessionID: "no-token-session",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-30 * time.Minute),
- LastInteractionTime: &recentInteraction,
- Phase: session.PhaseActive,
- LastPrompt: "explain this code",
- AgentType: "Claude Code",
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if strings.Contains(output, "tokens") {
- t.Errorf("Session with no token data should NOT show tokens, got: %s", output)
- }
-}
-
-func TestWriteActiveSessions_ShowsTokensWithCheckpoints(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
- recentInteraction := now.Add(-5 * time.Minute)
-
- states := []*session.State{
- {
- SessionID: "has-checkpoint-session",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-30 * time.Minute),
- LastInteractionTime: &recentInteraction,
- Phase: session.PhaseActive,
- LastPrompt: "fix the bug",
- AgentType: "Claude Code",
- StepCount: 2,
- TokenUsage: &agent.TokenUsage{
- InputTokens: 800,
- OutputTokens: 400,
- },
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if !strings.Contains(output, "tokens 1.2k") {
- t.Errorf("Session with checkpoints should show tokens, got: %s", output)
- }
-}
-
-func TestRunStatus_DetailedDisabledDoesNotShowAgents(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsDisabled)
- writeClaudeHooksFixture(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, true, false); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- output := stdout.String()
- if strings.Contains(output, "Agents ·") {
- t.Errorf("Disabled detailed status should not show agents, got: %s", output)
- }
-}
-
-func TestRunStatus_DisabledDoesNotShowAgents(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsDisabled)
- writeClaudeHooksFixture(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, false); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- output := stdout.String()
- if strings.Contains(output, "Agents ·") {
- t.Errorf("Disabled status should not show agents, got: %s", output)
- }
-}
-
-func TestFormatSettingsStatus_Project(t *testing.T) {
- t.Parallel()
-
- sty := statusStyles{colorEnabled: false, width: 60}
- s := &TraceSettings{
- Enabled: true,
- Strategy: "manual-commit",
- }
-
- result := formatSettingsStatus("Project", s, sty)
-
- if !strings.Contains(result, "Project") {
- t.Errorf("Expected 'Project' prefix, got: %q", result)
- }
- if !strings.Contains(result, "enabled") {
- t.Errorf("Expected 'enabled' in output, got: %q", result)
- }
- if !strings.Contains(result, "manual-commit") {
- t.Errorf("Expected strategy in output, got: %q", result)
- }
-}
-
-func TestFormatSettingsStatus_LocalDisabled(t *testing.T) {
- t.Parallel()
-
- sty := statusStyles{colorEnabled: false, width: 60}
- s := &TraceSettings{
- Enabled: false,
- Strategy: "manual-commit",
- }
-
- result := formatSettingsStatus("Local", s, sty)
-
- if !strings.Contains(result, "Local") {
- t.Errorf("Expected 'Local' prefix, got: %q", result)
- }
- if !strings.Contains(result, "disabled") {
- t.Errorf("Expected 'disabled' in output, got: %q", result)
- }
- if !strings.Contains(result, "manual-commit") {
- t.Errorf("Expected strategy in output, got: %q", result)
- }
-}
-
-func TestWriteActiveSessions_StaleIndicator(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
- staleInteraction := now.Add(-2 * time.Hour) // well past 1hr threshold
-
- states := []*session.State{
- {
- SessionID: "stale-session-1",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-3 * time.Hour),
- LastInteractionTime: &staleInteraction,
- Phase: session.PhaseActive,
- LastPrompt: "fix the bug",
- AgentType: "Claude Code",
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if !strings.Contains(output, "stale") {
- t.Errorf("Expected 'stale' indicator for session with interaction >1hr ago, got: %s", output)
- }
- if !strings.Contains(output, "trace doctor") {
- t.Errorf("Expected 'trace doctor' hint in stale indicator, got: %s", output)
- }
-}
-
-func TestIsStuckActiveSession(t *testing.T) {
- t.Parallel()
-
- now := time.Now()
- recent := now.Add(-5 * time.Minute)
- stale := now.Add(-2 * time.Hour)
- brandNew := now.Add(-10 * time.Second)
-
- tests := []struct {
- name string
- state *session.State
- want bool
- }{
- {
- name: "active with stale interaction",
- state: &session.State{Phase: session.PhaseActive, LastInteractionTime: &stale},
- want: true,
- },
- {
- name: "active with nil interaction and old start",
- state: &session.State{Phase: session.PhaseActive, LastInteractionTime: nil, StartedAt: now.Add(-2 * time.Hour)},
- want: true,
- },
- {
- name: "active with nil interaction and recent start",
- state: &session.State{Phase: session.PhaseActive, LastInteractionTime: nil, StartedAt: brandNew},
- want: false,
- },
- {
- name: "active with recent interaction",
- state: &session.State{Phase: session.PhaseActive, LastInteractionTime: &recent},
- want: false,
- },
- {
- name: "idle with stale interaction",
- state: &session.State{Phase: session.PhaseIdle, LastInteractionTime: &stale},
- want: false,
- },
- {
- name: "ended with stale interaction",
- state: &session.State{Phase: session.PhaseEnded, LastInteractionTime: &stale},
- want: false,
- },
- {
- name: "empty phase with stale interaction",
- state: &session.State{Phase: "", LastInteractionTime: &stale},
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- if got := tt.state.IsStuckActive(); got != tt.want {
- t.Errorf("IsStuckActive() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestWriteActiveSessions_StaleWithNilInteractionOldStart(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
-
- states := []*session.State{
- {
- SessionID: "old-nil-interaction-session",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-2 * time.Hour),
- LastInteractionTime: nil,
- Phase: session.PhaseActive,
- LastPrompt: "do something",
- AgentType: "Claude Code",
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if !strings.Contains(output, "stale") {
- t.Errorf("Old session with nil LastInteractionTime should show stale indicator, got: %s", output)
- }
-}
-
-func TestWriteActiveSessions_NotStaleWhenBrandNew(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
-
- states := []*session.State{
- {
- SessionID: "brand-new-session",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-10 * time.Second),
- LastInteractionTime: nil,
- Phase: session.PhaseActive,
- LastPrompt: "hello",
- AgentType: "Claude Code",
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if strings.Contains(output, "stale") {
- t.Errorf("Brand-new session should NOT show stale indicator, got: %s", output)
- }
-}
-
-func TestWriteActiveSessions_NotStaleWhenRecent(t *testing.T) {
- setupTestRepo(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
- recentInteraction := now.Add(-5 * time.Minute)
-
- states := []*session.State{
- {
- SessionID: "fresh-session-1",
- WorktreePath: "/Users/test/repo",
- StartedAt: now.Add(-30 * time.Minute),
- LastInteractionTime: &recentInteraction,
- Phase: session.PhaseActive,
- LastPrompt: "add feature",
- AgentType: "Claude Code",
- },
- }
-
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var buf bytes.Buffer
- sty := newStatusStyles(&buf)
- writeActiveSessions(context.Background(), &buf, sty)
-
- output := buf.String()
-
- if strings.Contains(output, "stale") {
- t.Errorf("Session with recent interaction should NOT show stale indicator, got: %s", output)
- }
-}
-
-func TestFormatSettingsStatus_Separators(t *testing.T) {
- t.Parallel()
-
- sty := statusStyles{colorEnabled: false, width: 60}
- s := &TraceSettings{
- Enabled: true,
- Strategy: "manual-commit",
- }
-
- result := formatSettingsStatus("Project", s, sty)
-
- // Should use · as separator (plain text, no ANSI)
- if !strings.Contains(result, "·") {
- t.Errorf("Expected '·' separators in output, got: %q", result)
- }
-}
-
-func TestRunStatusJSON_Enabled(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if !result.Enabled {
- t.Error("Expected enabled=true")
- }
- found := false
- for _, a := range result.Agents {
- if a == "Claude Code" {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("Expected agents to contain 'Claude Code', got %v", result.Agents)
- }
- if result.Error != "" {
- t.Errorf("Expected no error, got %q", result.Error)
- }
-}
-
-func TestRunStatusJSON_Disabled(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsDisabled)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if result.Enabled {
- t.Error("Expected enabled=false")
- }
-}
-
-func TestRunStatusJSON_NotSetUp(t *testing.T) {
- setupTestRepo(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if result.Enabled {
- t.Error("Expected enabled=false")
- }
- if result.Error != "not set up" {
- t.Errorf("Expected error='not set up', got %q", result.Error)
- }
-}
-
-func TestRunStatusJSON_NotGitRepo(t *testing.T) {
- setupTestDir(t)
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if result.Enabled {
- t.Error("Expected enabled=false")
- }
- if result.Error != "not a git repository" {
- t.Errorf("Expected error='not a git repository', got %q", result.Error)
- }
-}
-
-func TestRunStatusJSON_WithActiveSessions(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
- writeClaudeHooksFixture(t)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- state := &session.State{
- SessionID: "test-json-session",
- WorktreePath: "/test/repo",
- StartedAt: time.Now(),
- Phase: session.PhaseActive,
- AgentType: "Claude Code",
- ModelName: "sonnet-4.1",
- }
- if err := store.Save(context.Background(), state); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if len(result.ActiveSessions) != 1 {
- t.Fatalf("Expected 1 active session, got %d", len(result.ActiveSessions))
- }
- s := result.ActiveSessions[0]
- if s.Agent != "Claude Code" {
- t.Errorf("Expected agent='Claude Code', got %q", s.Agent)
- }
- if s.Model != "sonnet-4.1" {
- t.Errorf("Expected model='sonnet-4.1', got %q", s.Model)
- }
- if s.Status != "active" {
- t.Errorf("Expected status='active', got %q", s.Status)
- }
-}
-
-func TestRunStatusJSON_DeduplicatesSessions(t *testing.T) {
- setupTestRepo(t)
- writeSettings(t, testSettingsEnabled)
-
- store, err := session.NewStateStore(context.Background())
- if err != nil {
- t.Fatalf("NewStateStore() error = %v", err)
- }
-
- now := time.Now()
- states := []*session.State{
- {
- SessionID: "codex-idle-1",
- WorktreePath: "/test/repo",
- StartedAt: now.Add(-30 * time.Minute),
- Phase: session.PhaseIdle,
- AgentType: "Codex",
- },
- {
- SessionID: "codex-idle-2",
- WorktreePath: "/test/repo",
- StartedAt: now.Add(-20 * time.Minute),
- Phase: session.PhaseIdle,
- AgentType: "Codex",
- },
- {
- SessionID: "codex-active",
- WorktreePath: "/test/repo",
- StartedAt: now.Add(-5 * time.Minute),
- Phase: session.PhaseActive,
- AgentType: "Codex",
- ModelName: "codex-mini",
- },
- }
- for _, s := range states {
- if err := store.Save(context.Background(), s); err != nil {
- t.Fatalf("Save() error = %v", err)
- }
- }
-
- var stdout bytes.Buffer
- if err := runStatus(context.Background(), &stdout, false, true); err != nil {
- t.Fatalf("runStatus() error = %v", err)
- }
-
- var result statusJSON
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("json.Unmarshal() error = %v", err)
- }
-
- if len(result.ActiveSessions) != 1 {
- t.Fatalf("Expected 1 deduplicated session, got %d", len(result.ActiveSessions))
- }
- s := result.ActiveSessions[0]
- if s.Agent != "Codex" {
- t.Errorf("Expected agent='Codex', got %q", s.Agent)
- }
- if s.Status != "active" {
- t.Errorf("Expected status='active' (active wins over idle), got %q", s.Status)
- }
- if s.Model != "codex-mini" {
- t.Errorf("Expected model='codex-mini' from active session, got %q", s.Model)
- }
-}
diff --git a/cli/strategy/checkpoint_remote_2_test.go b/cli/strategy/checkpoint_remote_2_test.go
new file mode 100644
index 0000000..b10789a
--- /dev/null
+++ b/cli/strategy/checkpoint_remote_2_test.go
@@ -0,0 +1,237 @@
+package strategy
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// createV2MainRef creates a v2 /main custom ref with a single orphan commit.
+// Uses git plumbing to create the ref under refs/trace/ (not refs/heads/).
+// Each call produces a distinct commit (uses a sequence counter in content).
+func createV2MainRef(ctx context.Context, t *testing.T, repoDir string) {
+ t.Helper()
+ v2RefSeq++
+
+ cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "--stdin")
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ cmd.Stdin = strings.NewReader(fmt.Sprintf(`{"test": true, "seq": %d}`, v2RefSeq))
+ blobOut, err := cmd.Output()
+ require.NoError(t, err)
+ blobHash := strings.TrimSpace(string(blobOut))
+
+ cmd = exec.CommandContext(ctx, "git", "mktree")
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ cmd.Stdin = strings.NewReader("100644 blob " + blobHash + "\tmetadata.json\n")
+ treeOut, err := cmd.Output()
+ require.NoError(t, err)
+ treeHash := strings.TrimSpace(string(treeOut))
+
+ cmd = exec.CommandContext(ctx, "git", "commit-tree", "-m", fmt.Sprintf("v2 checkpoint %d", v2RefSeq), treeHash)
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ commitOut, err := cmd.Output()
+ require.NoError(t, err)
+ commitHash := strings.TrimSpace(string(commitOut))
+
+ cmd = exec.CommandContext(ctx, "git", "update-ref", paths.V2MainRefName, commitHash)
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ require.NoError(t, cmd.Run())
+}
+
+// refExists checks whether a custom ref exists in the repo.
+func refExists(ctx context.Context, t *testing.T, repoDir, refName string) bool {
+ t.Helper()
+ cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", refName)
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ return cmd.Run() == nil
+}
+
+// Not parallel: uses t.Chdir()
+func TestFetchV2MainFromURL_FetchesRef(t *testing.T) {
+ ctx := context.Background()
+
+ // Set up "remote" repo with v2 /main ref
+ remoteDir := t.TempDir()
+ testutil.InitRepo(t, remoteDir)
+ testutil.WriteFile(t, remoteDir, "f.txt", "init")
+ testutil.GitAdd(t, remoteDir, "f.txt")
+ testutil.GitCommit(t, remoteDir, "init")
+ createV2MainRef(ctx, t, remoteDir)
+
+ // Set up local repo
+ localDir := t.TempDir()
+ testutil.InitRepo(t, localDir)
+ testutil.WriteFile(t, localDir, "f.txt", "init")
+ testutil.GitAdd(t, localDir, "f.txt")
+ testutil.GitCommit(t, localDir, "init")
+
+ t.Chdir(localDir)
+
+ // Ref doesn't exist yet
+ assert.False(t, refExists(ctx, t, localDir, paths.V2MainRefName))
+
+ // Fetch from "remote"
+ require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
+
+ // Ref should now exist
+ assert.True(t, refExists(ctx, t, localDir, paths.V2MainRefName))
+}
+
+// Not parallel: uses t.Chdir()
+func TestFetchV2MainFromURL_UpdatesExistingRef(t *testing.T) {
+ ctx := context.Background()
+
+ // Set up "remote" repo with v2 /main ref
+ remoteDir := t.TempDir()
+ testutil.InitRepo(t, remoteDir)
+ testutil.WriteFile(t, remoteDir, "f.txt", "init")
+ testutil.GitAdd(t, remoteDir, "f.txt")
+ testutil.GitCommit(t, remoteDir, "init")
+ createV2MainRef(ctx, t, remoteDir)
+
+ // Set up local repo and fetch once
+ localDir := t.TempDir()
+ testutil.InitRepo(t, localDir)
+ testutil.WriteFile(t, localDir, "f.txt", "init")
+ testutil.GitAdd(t, localDir, "f.txt")
+ testutil.GitCommit(t, localDir, "init")
+
+ t.Chdir(localDir)
+
+ require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
+
+ // Record initial hash
+ hashCmd := exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
+ hashCmd.Dir = localDir
+ hashCmd.Env = testutil.GitIsolatedEnv()
+ hash1Out, err := hashCmd.Output()
+ require.NoError(t, err)
+ hash1 := strings.TrimSpace(string(hash1Out))
+
+ // Add a second commit on the remote's v2 ref
+ createV2MainRef(ctx, t, remoteDir) // Creates a new orphan commit, updating the ref
+
+ // Fetch again — should update
+ require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
+
+ hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
+ hashCmd.Dir = localDir
+ hashCmd.Env = testutil.GitIsolatedEnv()
+ hash2Out, err := hashCmd.Output()
+ require.NoError(t, err)
+ hash2 := strings.TrimSpace(string(hash2Out))
+
+ assert.NotEqual(t, hash1, hash2, "FetchV2MainFromURL should update existing ref to new remote tip")
+}
+
+// TestFetchV2MainFromURL_DoesNotRewindLocalAhead verifies that fetching the v2
+// /main ref from a remote whose tip is at A does NOT rewind a locally-ahead
+// ref at B (A's descendant). The buggy version used a direct-write refspec
+// `+refs/trace/v2/main:refs/trace/v2/main` which git applies before Go can
+// intercept — orphaning locally-committed-but-unpushed v2 checkpoints.
+//
+// Not parallel: uses t.Chdir().
+func TestFetchV2MainFromURL_DoesNotRewindLocalAhead(t *testing.T) {
+ ctx := context.Background()
+
+ // Set up remote with v2 /main ref at commit A.
+ remoteDir := t.TempDir()
+ testutil.InitRepo(t, remoteDir)
+ testutil.WriteFile(t, remoteDir, "f.txt", "init")
+ testutil.GitAdd(t, remoteDir, "f.txt")
+ testutil.GitCommit(t, remoteDir, "init")
+ createV2MainRef(ctx, t, remoteDir)
+
+ // Set up local repo and fetch once so local v2 /main is at A.
+ localDir := t.TempDir()
+ testutil.InitRepo(t, localDir)
+ testutil.WriteFile(t, localDir, "f.txt", "init")
+ testutil.GitAdd(t, localDir, "f.txt")
+ testutil.GitCommit(t, localDir, "init")
+ t.Chdir(localDir)
+
+ require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
+
+ hashCmd := exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
+ hashCmd.Dir = localDir
+ hashCmd.Env = testutil.GitIsolatedEnv()
+ aOut, err := hashCmd.Output()
+ require.NoError(t, err)
+ aHash := strings.TrimSpace(string(aOut))
+
+ // Advance local v2 /main to B, with A as parent (descendant relationship).
+ // This mirrors the real flow where condensation appends a new checkpoint
+ // commit on top of the previous tip.
+ advanceV2MainOnTop(ctx, t, localDir, aHash)
+
+ hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
+ hashCmd.Dir = localDir
+ hashCmd.Env = testutil.GitIsolatedEnv()
+ bOut, err := hashCmd.Output()
+ require.NoError(t, err)
+ bHash := strings.TrimSpace(string(bOut))
+ require.NotEqual(t, aHash, bHash, "test setup: local v2 /main should have advanced beyond remote")
+
+ // Fetch from remote — must NOT rewind local from B to A.
+ require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
+
+ hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
+ hashCmd.Dir = localDir
+ hashCmd.Env = testutil.GitIsolatedEnv()
+ afterOut, err := hashCmd.Output()
+ require.NoError(t, err)
+ afterHash := strings.TrimSpace(string(afterOut))
+
+ assert.Equal(t, bHash, afterHash,
+ "FetchV2MainFromURL must not rewind locally-ahead v2 /main; expected %s (B), got %s (A=%s)",
+ bHash, afterHash, aHash)
+}
+
+// advanceV2MainOnTop creates a new v2 /main commit whose parent is parentHash,
+// and updates refs/trace/checkpoints/v2/main to point at it. Used to simulate
+// a locally-ahead ref in tests.
+func advanceV2MainOnTop(ctx context.Context, t *testing.T, repoDir, parentHash string) {
+ t.Helper()
+ v2RefSeq++
+
+ cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "--stdin")
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ cmd.Stdin = strings.NewReader(fmt.Sprintf(`{"advance": %d}`, v2RefSeq))
+ blobOut, err := cmd.Output()
+ require.NoError(t, err)
+ blobHash := strings.TrimSpace(string(blobOut))
+
+ cmd = exec.CommandContext(ctx, "git", "mktree")
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ cmd.Stdin = strings.NewReader("100644 blob " + blobHash + "\tadvance.json\n")
+ treeOut, err := cmd.Output()
+ require.NoError(t, err)
+ treeHash := strings.TrimSpace(string(treeOut))
+
+ cmd = exec.CommandContext(ctx, "git", "commit-tree", "-p", parentHash, "-m", fmt.Sprintf("advance %d", v2RefSeq), treeHash)
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ commitOut, err := cmd.Output()
+ require.NoError(t, err)
+ commitHash := strings.TrimSpace(string(commitOut))
+
+ cmd = exec.CommandContext(ctx, "git", "update-ref", paths.V2MainRefName, commitHash)
+ cmd.Dir = repoDir
+ cmd.Env = testutil.GitIsolatedEnv()
+ require.NoError(t, cmd.Run())
+}
diff --git a/cli/strategy/checkpoint_remote_test.go b/cli/strategy/checkpoint_remote_test.go
index cabe123..68825a7 100644
--- a/cli/strategy/checkpoint_remote_test.go
+++ b/cli/strategy/checkpoint_remote_test.go
@@ -2,7 +2,6 @@ package strategy
import (
"context"
- "fmt"
"os"
"os/exec"
"path/filepath"
@@ -10,7 +9,6 @@ import (
"testing"
"github.com/GrayCodeAI/trace/cli/checkpoint/remote"
- "github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/testutil"
@@ -810,225 +808,3 @@ func TestFetchMetadataBranch_DoesNotRewindLocalAhead(t *testing.T) {
// v2RefSeq is a counter to ensure each call to createV2MainRef produces a distinct commit.
var v2RefSeq int
-
-// createV2MainRef creates a v2 /main custom ref with a single orphan commit.
-// Uses git plumbing to create the ref under refs/trace/ (not refs/heads/).
-// Each call produces a distinct commit (uses a sequence counter in content).
-func createV2MainRef(ctx context.Context, t *testing.T, repoDir string) {
- t.Helper()
- v2RefSeq++
-
- cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "--stdin")
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- cmd.Stdin = strings.NewReader(fmt.Sprintf(`{"test": true, "seq": %d}`, v2RefSeq))
- blobOut, err := cmd.Output()
- require.NoError(t, err)
- blobHash := strings.TrimSpace(string(blobOut))
-
- cmd = exec.CommandContext(ctx, "git", "mktree")
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- cmd.Stdin = strings.NewReader("100644 blob " + blobHash + "\tmetadata.json\n")
- treeOut, err := cmd.Output()
- require.NoError(t, err)
- treeHash := strings.TrimSpace(string(treeOut))
-
- cmd = exec.CommandContext(ctx, "git", "commit-tree", "-m", fmt.Sprintf("v2 checkpoint %d", v2RefSeq), treeHash)
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- commitOut, err := cmd.Output()
- require.NoError(t, err)
- commitHash := strings.TrimSpace(string(commitOut))
-
- cmd = exec.CommandContext(ctx, "git", "update-ref", paths.V2MainRefName, commitHash)
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- require.NoError(t, cmd.Run())
-}
-
-// refExists checks whether a custom ref exists in the repo.
-func refExists(ctx context.Context, t *testing.T, repoDir, refName string) bool {
- t.Helper()
- cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", refName)
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- return cmd.Run() == nil
-}
-
-// Not parallel: uses t.Chdir()
-func TestFetchV2MainFromURL_FetchesRef(t *testing.T) {
- ctx := context.Background()
-
- // Set up "remote" repo with v2 /main ref
- remoteDir := t.TempDir()
- testutil.InitRepo(t, remoteDir)
- testutil.WriteFile(t, remoteDir, "f.txt", "init")
- testutil.GitAdd(t, remoteDir, "f.txt")
- testutil.GitCommit(t, remoteDir, "init")
- createV2MainRef(ctx, t, remoteDir)
-
- // Set up local repo
- localDir := t.TempDir()
- testutil.InitRepo(t, localDir)
- testutil.WriteFile(t, localDir, "f.txt", "init")
- testutil.GitAdd(t, localDir, "f.txt")
- testutil.GitCommit(t, localDir, "init")
-
- t.Chdir(localDir)
-
- // Ref doesn't exist yet
- assert.False(t, refExists(ctx, t, localDir, paths.V2MainRefName))
-
- // Fetch from "remote"
- require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
-
- // Ref should now exist
- assert.True(t, refExists(ctx, t, localDir, paths.V2MainRefName))
-}
-
-// Not parallel: uses t.Chdir()
-func TestFetchV2MainFromURL_UpdatesExistingRef(t *testing.T) {
- ctx := context.Background()
-
- // Set up "remote" repo with v2 /main ref
- remoteDir := t.TempDir()
- testutil.InitRepo(t, remoteDir)
- testutil.WriteFile(t, remoteDir, "f.txt", "init")
- testutil.GitAdd(t, remoteDir, "f.txt")
- testutil.GitCommit(t, remoteDir, "init")
- createV2MainRef(ctx, t, remoteDir)
-
- // Set up local repo and fetch once
- localDir := t.TempDir()
- testutil.InitRepo(t, localDir)
- testutil.WriteFile(t, localDir, "f.txt", "init")
- testutil.GitAdd(t, localDir, "f.txt")
- testutil.GitCommit(t, localDir, "init")
-
- t.Chdir(localDir)
-
- require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
-
- // Record initial hash
- hashCmd := exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
- hashCmd.Dir = localDir
- hashCmd.Env = testutil.GitIsolatedEnv()
- hash1Out, err := hashCmd.Output()
- require.NoError(t, err)
- hash1 := strings.TrimSpace(string(hash1Out))
-
- // Add a second commit on the remote's v2 ref
- createV2MainRef(ctx, t, remoteDir) // Creates a new orphan commit, updating the ref
-
- // Fetch again — should update
- require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
-
- hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
- hashCmd.Dir = localDir
- hashCmd.Env = testutil.GitIsolatedEnv()
- hash2Out, err := hashCmd.Output()
- require.NoError(t, err)
- hash2 := strings.TrimSpace(string(hash2Out))
-
- assert.NotEqual(t, hash1, hash2, "FetchV2MainFromURL should update existing ref to new remote tip")
-}
-
-// TestFetchV2MainFromURL_DoesNotRewindLocalAhead verifies that fetching the v2
-// /main ref from a remote whose tip is at A does NOT rewind a locally-ahead
-// ref at B (A's descendant). The buggy version used a direct-write refspec
-// `+refs/trace/v2/main:refs/trace/v2/main` which git applies before Go can
-// intercept — orphaning locally-committed-but-unpushed v2 checkpoints.
-//
-// Not parallel: uses t.Chdir().
-func TestFetchV2MainFromURL_DoesNotRewindLocalAhead(t *testing.T) {
- ctx := context.Background()
-
- // Set up remote with v2 /main ref at commit A.
- remoteDir := t.TempDir()
- testutil.InitRepo(t, remoteDir)
- testutil.WriteFile(t, remoteDir, "f.txt", "init")
- testutil.GitAdd(t, remoteDir, "f.txt")
- testutil.GitCommit(t, remoteDir, "init")
- createV2MainRef(ctx, t, remoteDir)
-
- // Set up local repo and fetch once so local v2 /main is at A.
- localDir := t.TempDir()
- testutil.InitRepo(t, localDir)
- testutil.WriteFile(t, localDir, "f.txt", "init")
- testutil.GitAdd(t, localDir, "f.txt")
- testutil.GitCommit(t, localDir, "init")
- t.Chdir(localDir)
-
- require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
-
- hashCmd := exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
- hashCmd.Dir = localDir
- hashCmd.Env = testutil.GitIsolatedEnv()
- aOut, err := hashCmd.Output()
- require.NoError(t, err)
- aHash := strings.TrimSpace(string(aOut))
-
- // Advance local v2 /main to B, with A as parent (descendant relationship).
- // This mirrors the real flow where condensation appends a new checkpoint
- // commit on top of the previous tip.
- advanceV2MainOnTop(ctx, t, localDir, aHash)
-
- hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
- hashCmd.Dir = localDir
- hashCmd.Env = testutil.GitIsolatedEnv()
- bOut, err := hashCmd.Output()
- require.NoError(t, err)
- bHash := strings.TrimSpace(string(bOut))
- require.NotEqual(t, aHash, bHash, "test setup: local v2 /main should have advanced beyond remote")
-
- // Fetch from remote — must NOT rewind local from B to A.
- require.NoError(t, FetchV2MainFromURL(ctx, remoteDir))
-
- hashCmd = exec.CommandContext(ctx, "git", "rev-parse", paths.V2MainRefName)
- hashCmd.Dir = localDir
- hashCmd.Env = testutil.GitIsolatedEnv()
- afterOut, err := hashCmd.Output()
- require.NoError(t, err)
- afterHash := strings.TrimSpace(string(afterOut))
-
- assert.Equal(t, bHash, afterHash,
- "FetchV2MainFromURL must not rewind locally-ahead v2 /main; expected %s (B), got %s (A=%s)",
- bHash, afterHash, aHash)
-}
-
-// advanceV2MainOnTop creates a new v2 /main commit whose parent is parentHash,
-// and updates refs/trace/checkpoints/v2/main to point at it. Used to simulate
-// a locally-ahead ref in tests.
-func advanceV2MainOnTop(ctx context.Context, t *testing.T, repoDir, parentHash string) {
- t.Helper()
- v2RefSeq++
-
- cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "--stdin")
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- cmd.Stdin = strings.NewReader(fmt.Sprintf(`{"advance": %d}`, v2RefSeq))
- blobOut, err := cmd.Output()
- require.NoError(t, err)
- blobHash := strings.TrimSpace(string(blobOut))
-
- cmd = exec.CommandContext(ctx, "git", "mktree")
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- cmd.Stdin = strings.NewReader("100644 blob " + blobHash + "\tadvance.json\n")
- treeOut, err := cmd.Output()
- require.NoError(t, err)
- treeHash := strings.TrimSpace(string(treeOut))
-
- cmd = exec.CommandContext(ctx, "git", "commit-tree", "-p", parentHash, "-m", fmt.Sprintf("advance %d", v2RefSeq), treeHash)
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- commitOut, err := cmd.Output()
- require.NoError(t, err)
- commitHash := strings.TrimSpace(string(commitOut))
-
- cmd = exec.CommandContext(ctx, "git", "update-ref", paths.V2MainRefName, commitHash)
- cmd.Dir = repoDir
- cmd.Env = testutil.GitIsolatedEnv()
- require.NoError(t, cmd.Run())
-}
diff --git a/cli/strategy/common.go b/cli/strategy/common.go
index 36b0384..6c8aa35 100644
--- a/cli/strategy/common.go
+++ b/cli/strategy/common.go
@@ -7,10 +7,7 @@ import (
"fmt"
"log/slog"
"os"
- "os/exec"
- "path/filepath"
"sort"
- "strconv"
"strings"
"sync"
"time"
@@ -22,7 +19,6 @@ import (
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/settings"
- "github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/cli/vercelconfig"
"github.com/GrayCodeAI/trace/redact"
@@ -829,959 +825,3 @@ func ReadAgentTypeFromTree(tree *object.Tree, checkpointPath string) types.Agent
}
return agent.AgentTypeUnknown
}
-
-// isOnlySeparators checks if a string contains only dashes, spaces, and newlines.
-func isOnlySeparators(s string) bool {
- for _, r := range s {
- if r != '-' && r != ' ' && r != '\n' && r != '\r' && r != '\t' {
- return false
- }
- }
- return true
-}
-
-// ReadLatestSessionPromptFromCommittedTree reads the first prompt from a committed checkpoint's
-// latest session on the metadata branch tree. This navigates the sharded directory layout:
-// //prompt.txt
-//
-// Falls back through earlier sessions when the latest has no prompt.
-// Avoids reading full transcripts — only reads prompt.txt files.
-// sessionCount is the number of sessions in the checkpoint (from CommittedInfo.SessionCount).
-func ReadLatestSessionPromptFromCommittedTree(tree *object.Tree, cpID id.CheckpointID, sessionCount int) string {
- cpPath := cpID.Path()
- cpTree, err := tree.Tree(cpPath)
- if err != nil {
- return ""
- }
-
- // Find the latest session subdirectory with a prompt.
- // Sessions use 0-based indexing: 0/, 1/, 2/, etc.
- // Start from the latest and fall back through earlier sessions
- // when the latest has no prompt (e.g. a test or empty session was
- // condensed alongside a real one).
- latestIndex := max(sessionCount-1, 0)
-
- for i := latestIndex; i >= 0; i-- {
- sessionPath := strconv.Itoa(i)
- sessionTree, err := cpTree.Tree(sessionPath)
- if err != nil {
- continue
- }
-
- file, err := sessionTree.File(paths.PromptFileName)
- if err != nil {
- continue
- }
-
- content, err := file.Contents()
- if err != nil {
- continue
- }
-
- if prompt := ExtractFirstPrompt(content); prompt != "" {
- return prompt
- }
- }
-
- return ""
-}
-
-// ReadAllSessionPromptsFromTree reads the first prompt for all sessions in a multi-session checkpoint.
-// Returns a slice of prompts parallel to sessionIDs (oldest to newest).
-// For single-session checkpoints, returns a slice with just the root prompt.
-func ReadAllSessionPromptsFromTree(tree *object.Tree, checkpointPath string, sessionCount int, sessionIDs []string) []string {
- if sessionCount <= 1 || len(sessionIDs) <= 1 {
- // Single session - just return the root prompt
- prompt := ReadSessionPromptFromTree(tree, checkpointPath)
- if prompt != "" {
- return []string{prompt}
- }
- return nil
- }
-
- // Multi-session: read prompts from archived folders (0/, 1/, etc.) and root
- prompts := make([]string, len(sessionIDs))
-
- // Read archived session prompts (folders 0, 1, ... N-2)
- for i := range sessionCount - 1 {
- archivedPath := fmt.Sprintf("%s/%d", checkpointPath, i)
- prompts[i] = ReadSessionPromptFromTree(tree, archivedPath)
- }
-
- // Read the most recent session prompt (at root level)
- prompts[len(prompts)-1] = ReadSessionPromptFromTree(tree, checkpointPath)
-
- return prompts
-}
-
-// GetRemoteMetadataBranchTree returns the tree object for origin/trace/checkpoints/v1.
-func GetRemoteMetadataBranchTree(repo *git.Repository) (*object.Tree, error) {
- refName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- return nil, fmt.Errorf("failed to get remote metadata branch reference: %w", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return nil, fmt.Errorf("failed to get remote metadata branch commit: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get remote metadata branch tree: %w", err)
- }
- return tree, nil
-}
-
-// OpenRepository opens the git repository from the repo root.
-// Each call returns a fresh instance to avoid storer contention between
-// concurrent goroutines — go-git's filesystem storer is not safe for
-// concurrent read+write even across separate Repository instances that
-// share the same .git directory.
-func OpenRepository(ctx context.Context) (*git.Repository, error) {
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- // Fallback to current directory if git command fails
- // (e.g., if git is not installed or we're not in a repo)
- repoRoot = "."
- }
-
- repo, err := git.PlainOpen(repoRoot)
- if err != nil {
- return nil, fmt.Errorf("failed to open repository: %w", err)
- }
- return repo, nil
-}
-
-// IsInsideWorktree returns true if the current directory is inside a git worktree
-// (as opposed to the main repository). Worktrees have .git as a file pointing
-// to the main repo, while the main repo has .git as a directory.
-// This function works correctly from any subdirectory within the repository.
-func IsInsideWorktree(ctx context.Context) bool {
- // First find the repository root
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return false
- }
-
- gitPath := filepath.Join(repoRoot, gitDir)
- gitInfo, err := os.Stat(gitPath)
- if err != nil {
- return false
- }
- return !gitInfo.IsDir()
-}
-
-// GetMainRepoRoot returns the root directory of the main repository.
-// In the main repo, this is the worktree path (repo root).
-// In a worktree, this parses the .git file to find the main repo.
-// This function works correctly from any subdirectory within the repository.
-//
-// Per gitrepository-layout(5), a worktree's .git file is a "gitfile" containing
-// "gitdir: " pointing to $GIT_DIR/worktrees/ in the main repository.
-// See: https://git-scm.com/docs/gitrepository-layout
-func GetMainRepoRoot(ctx context.Context) (string, error) {
- // First find the worktree/repo root
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return "", fmt.Errorf("failed to get worktree path: %w", err)
- }
-
- if !IsInsideWorktree(ctx) {
- return repoRoot, nil
- }
-
- // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/"
- gitFilePath := filepath.Join(repoRoot, gitDir)
- content, err := os.ReadFile(gitFilePath) //nolint:gosec // G304: gitFilePath is constructed from repo root, not user input
- if err != nil {
- return "", fmt.Errorf("failed to read .git file: %w", err)
- }
-
- gitdir := strings.TrimSpace(string(content))
- gitdir = strings.TrimPrefix(gitdir, "gitdir: ")
-
- // Extract main repo root: everything before "/.git/"
- idx := strings.LastIndex(gitdir, "/.git/")
- if idx < 0 {
- return "", fmt.Errorf("unexpected gitdir format: %s", gitdir)
- }
- return gitdir[:idx], nil
-}
-
-// GetGitCommonDir returns the path to the shared git directory.
-// In a regular checkout, this is .git/
-// In a worktree, this is the main repo's .git/ (not .git/worktrees//)
-// Uses git rev-parse --git-common-dir for reliable handling of worktrees.
-func GetGitCommonDir(ctx context.Context) (string, error) {
- cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
- cmd.Dir = "."
- output, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("failed to get git common dir: %w", err)
- }
-
- commonDir := strings.TrimSpace(string(output))
-
- // git rev-parse --git-common-dir returns relative paths from the working directory,
- // so we need to make it absolute if it isn't already
- if !filepath.IsAbs(commonDir) {
- commonDir = filepath.Join(".", commonDir)
- }
-
- return filepath.Clean(commonDir), nil
-}
-
-// EnsureTraceGitignore ensures all required entries are in .trace/.gitignore
-// Works correctly from any subdirectory within the repository.
-func EnsureTraceGitignore(ctx context.Context) error {
- // Get absolute path for the gitignore file
- gitignoreAbs, err := paths.AbsPath(ctx, traceGitignore)
- if err != nil {
- gitignoreAbs = traceGitignore // Fallback to relative
- }
-
- // Read existing content
- var content string
- if data, err := os.ReadFile(gitignoreAbs); err == nil { //nolint:gosec // path is from AbsPath or constant
- content = string(data)
- }
-
- // All entries that should be in .trace/.gitignore
- requiredEntries := []string{
- "tmp/",
- "settings.local.json",
- "metadata/",
- "logs/",
- }
-
- // Track what needs to be added
- var toAdd []string
- for _, entry := range requiredEntries {
- if !strings.Contains(content, entry) {
- toAdd = append(toAdd, entry)
- }
- }
-
- // Nothing to add
- if len(toAdd) == 0 {
- return nil
- }
-
- // Ensure .trace directory exists
- if err := os.MkdirAll(filepath.Dir(gitignoreAbs), 0o750); err != nil {
- return fmt.Errorf("failed to create .trace directory: %w", err)
- }
-
- // Append missing entries to gitignore
- var sb strings.Builder
- for _, entry := range toAdd {
- sb.WriteString(entry + "\n")
- }
- content += sb.String()
-
- if err := os.WriteFile(gitignoreAbs, []byte(content), 0o644); err != nil { //nolint:gosec // path is from AbsPath or constant
- return fmt.Errorf("failed to write gitignore: %w", err)
- }
- return nil
-}
-
-// checkCanRewindWithWarning checks working directory and returns a warning with diff stats.
-// Always returns canRewind=true but includes a warning message with +/- line stats for
-// uncommitted changes. Used by manual-commit strategy.
-func checkCanRewindWithWarning(ctx context.Context) (bool, string, error) {
- repo, err := OpenRepository(ctx)
- if err != nil {
- // Can't open repo - still allow rewind but without stats
- return true, "", nil //nolint:nilerr // Rewind allowed even if repo can't be opened
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even if worktree can't be accessed
- }
-
- status, err := worktree.Status()
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even if status can't be retrieved
- }
-
- if status.IsClean() {
- return true, "", nil
- }
-
- // Get HEAD commit tree for comparison - if we can't get it, just return without stats
- head, err := repo.Head()
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even without HEAD (e.g., empty repo)
- }
-
- headCommit, err := repo.CommitObject(head.Hash())
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even if commit lookup fails
- }
-
- headTree, err := headCommit.Tree()
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even if tree lookup fails
- }
-
- type fileChange struct {
- status string // "modified", "added", "deleted"
- added int
- removed int
- filename string
- }
-
- var changes []fileChange
- // Use repo root, not cwd - git status returns paths relative to repo root
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return true, "", nil //nolint:nilerr // Rewind allowed even if worktree root lookup fails
- }
-
- for file, st := range status {
- // Skip .trace directory
- if paths.IsInfrastructurePath(file) {
- continue
- }
-
- // Skip untracked files
- if st.Worktree == git.Untracked {
- continue
- }
-
- var change fileChange
- change.filename = file
-
- switch {
- case st.Staging == git.Added || st.Worktree == git.Added:
- change.status = "added"
- // New file - count all lines as added
- absPath := filepath.Join(repoRoot, file)
- if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status
- change.added = countLines(content)
- }
- case st.Staging == git.Deleted || st.Worktree == git.Deleted:
- change.status = "deleted"
- // Deleted file - count lines from HEAD as removed
- if entry, err := headTree.File(file); err == nil {
- if content, err := entry.Contents(); err == nil {
- change.removed = countLines([]byte(content))
- }
- }
- case st.Staging == git.Modified || st.Worktree == git.Modified:
- change.status = "modified"
- // Modified file - compute diff stats
- var headContent, workContent []byte
- if entry, err := headTree.File(file); err == nil {
- if content, err := entry.Contents(); err == nil {
- headContent = []byte(content)
- }
- }
- absPath := filepath.Join(repoRoot, file)
- if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status
- workContent = content
- }
- if headContent != nil && workContent != nil {
- change.added, change.removed = computeDiffStats(headContent, workContent)
- }
- default:
- continue
- }
-
- changes = append(changes, change)
- }
-
- if len(changes) == 0 {
- return true, "", nil
- }
-
- // Sort changes by filename for consistent output
- sort.Slice(changes, func(i, j int) bool {
- return changes[i].filename < changes[j].filename
- })
-
- var msg strings.Builder
- msg.WriteString("The following uncommitted changes will be reverted:\n")
-
- totalAdded, totalRemoved := 0, 0
- for _, c := range changes {
- totalAdded += c.added
- totalRemoved += c.removed
-
- var stats string
- switch {
- case c.added > 0 && c.removed > 0:
- stats = fmt.Sprintf("+%d/-%d", c.added, c.removed)
- case c.added > 0:
- stats = fmt.Sprintf("+%d", c.added)
- case c.removed > 0:
- stats = fmt.Sprintf("-%d", c.removed)
- }
-
- fmt.Fprintf(&msg, " %-10s %s", c.status+":", c.filename)
- if stats != "" {
- fmt.Fprintf(&msg, " (%s)", stats)
- }
- msg.WriteString("\n")
- }
-
- if totalAdded > 0 || totalRemoved > 0 {
- fmt.Fprintf(&msg, "\nTotal: +%d/-%d lines\n", totalAdded, totalRemoved)
- }
-
- return true, msg.String(), nil
-}
-
-// countLines counts the number of lines in content.
-func countLines(content []byte) int {
- if len(content) == 0 {
- return 0
- }
- count := 1
- for _, b := range content {
- if b == '\n' {
- count++
- }
- }
- // Don't count trailing newline as extra line
- if len(content) > 0 && content[len(content)-1] == '\n' {
- count--
- }
- return count
-}
-
-// computeDiffStats computes added and removed line counts between old and new content.
-// Uses a simple line-based diff algorithm.
-func computeDiffStats(oldContent, newContent []byte) (added, removed int) {
- oldLines := splitLines(oldContent)
- newLines := splitLines(newContent)
-
- // Build a set of old lines with counts
- oldSet := make(map[string]int)
- for _, line := range oldLines {
- oldSet[line]++
- }
-
- // Check which new lines are truly new
- for _, line := range newLines {
- if oldSet[line] > 0 {
- oldSet[line]--
- } else {
- added++
- }
- }
-
- // Remaining old lines are removed
- for _, count := range oldSet {
- removed += count
- }
-
- return added, removed
-}
-
-// splitLines splits content into lines, preserving empty lines.
-// Handles both Unix (\n) and Windows (\r\n) line endings.
-func splitLines(content []byte) []string {
- if len(content) == 0 {
- return nil
- }
- s := string(content)
- // Normalize Windows line endings to Unix
- s = strings.ReplaceAll(s, "\r\n", "\n")
- // Remove trailing newline to avoid empty last element
- s = strings.TrimSuffix(s, "\n")
- return strings.Split(s, "\n")
-}
-
-// fileExists checks if a file exists at the given path.
-func fileExists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
-}
-
-// getTaskCheckpointFromTree retrieves a task checkpoint from a commit tree.
-// Shared implementation for shadow and linear-shadow strategies.
-func getTaskCheckpointFromTree(ctx context.Context, point RewindPoint) (*TaskCheckpoint, error) {
- if !point.IsTaskCheckpoint {
- return nil, ErrNotTaskCheckpoint
- }
-
- repo, err := OpenRepository(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to open repository: %w", err)
- }
-
- commitHash := plumbing.NewHash(point.ID)
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- return nil, fmt.Errorf("failed to get commit: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get tree: %w", err)
- }
-
- // Read checkpoint.json from the tree
- checkpointPath := point.MetadataDir + "/checkpoint.json"
- file, err := tree.File(checkpointPath)
- if err != nil {
- return nil, fmt.Errorf("failed to find checkpoint at %s: %w", checkpointPath, err)
- }
-
- content, err := file.Contents()
- if err != nil {
- return nil, fmt.Errorf("failed to read checkpoint: %w", err)
- }
-
- var checkpoint TaskCheckpoint
- if err := json.Unmarshal([]byte(content), &checkpoint); err != nil {
- return nil, fmt.Errorf("failed to parse checkpoint: %w", err)
- }
-
- return &checkpoint, nil
-}
-
-// getTaskTranscriptFromTree retrieves a task transcript from a commit tree.
-// Shared implementation for shadow and linear-shadow strategies.
-func getTaskTranscriptFromTree(ctx context.Context, point RewindPoint) ([]byte, error) {
- if !point.IsTaskCheckpoint {
- return nil, ErrNotTaskCheckpoint
- }
-
- repo, err := OpenRepository(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to open repository: %w", err)
- }
-
- commitHash := plumbing.NewHash(point.ID)
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- return nil, fmt.Errorf("failed to get commit: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get tree: %w", err)
- }
-
- // MetadataDir format: .trace/metadata//tasks/
- // Session transcript is at: .trace/metadata//
- sessionDir := filepath.Dir(filepath.Dir(point.MetadataDir))
-
- // Try current format first, then legacy
- transcriptPath := sessionDir + "/" + paths.TranscriptFileName
- file, err := tree.File(transcriptPath)
- if err != nil {
- transcriptPath = sessionDir + "/" + paths.TranscriptFileNameLegacy
- file, err = tree.File(transcriptPath)
- if err != nil {
- return nil, fmt.Errorf("failed to find transcript: %w", err)
- }
- }
-
- content, err := file.Contents()
- if err != nil {
- return nil, fmt.Errorf("failed to read transcript: %w", err)
- }
-
- return []byte(content), nil
-}
-
-// ErrBranchNotFound is returned by DeleteBranchCLI when the branch does not exist.
-var ErrBranchNotFound = errors.New("branch not found")
-
-// ErrRefNotFound is returned by DeleteRefCLI when the ref does not exist.
-var ErrRefNotFound = errors.New("ref not found")
-
-// ErrRefChanged is returned by DeleteRefCLI when the ref no longer points to the expected OID.
-var ErrRefChanged = errors.New("ref changed since inspection")
-
-// DeleteBranchCLI deletes a git branch using the git CLI.
-// Uses `git branch -D` instead of go-git's RemoveReference because go-git v5
-// doesn't properly persist deletions when refs are packed (.git/packed-refs)
-// or in a worktree context. This is the same class of go-git v5 bug that
-// affects checkout and reset --hard (see HardResetWithProtection).
-//
-// Returns ErrBranchNotFound if the branch does not exist, allowing callers
-// to use errors.Is for idempotent deletion patterns.
-func DeleteBranchCLI(ctx context.Context, branchName string) error {
- // Pre-check: verify the branch exists so callers get a structured error
- // instead of parsing git's output string (which varies across locales).
- // git show-ref exits 1 for "not found" and 128+ for fatal errors (corrupt
- // repo, permissions, not a git directory). Only map exit code 1 to
- // ErrBranchNotFound; propagate other failures as-is.
- check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
- if err := check.Run(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
- return fmt.Errorf("%w: %s", ErrBranchNotFound, branchName)
- }
- return fmt.Errorf("failed to check branch %s: %w", branchName, err)
- }
-
- cmd := exec.CommandContext(ctx, "git", "branch", "-D", "--", branchName)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to delete branch %s: %s: %w", branchName, strings.TrimSpace(string(output)), err)
- }
- return nil
-}
-
-// DeleteRefCLI deletes an arbitrary ref using the git CLI.
-// Uses `git update-ref -d` instead of go-git's RemoveReference because go-git
-// ref deletion is unreliable with packed refs and worktrees.
-//
-// When expectedOID is non-empty, it is passed to `git update-ref -d [ `
-// as a compare-and-swap guard: git will refuse the deletion if the ref no longer
-// points to expectedOID, and ErrRefChanged is returned.
-//
-// Returns ErrRefNotFound if the ref does not exist, allowing callers to use
-// errors.Is for idempotent deletion patterns.
-func DeleteRefCLI(ctx context.Context, refName string, expectedOID string) error {
- exists, _, err := refStateCLI(ctx, refName)
- if err != nil {
- return err
- }
- if !exists {
- return fmt.Errorf("%w: %s", ErrRefNotFound, refName)
- }
-
- args := []string{"update-ref", "-d", refName}
- if expectedOID != "" {
- args = append(args, expectedOID)
- }
- cmd := exec.CommandContext(ctx, "git", args...)
- if output, err := cmd.CombinedOutput(); err != nil {
- return classifyDeleteRefFailure(ctx, refName, expectedOID, output, err)
- }
- return nil
-}
-
-func classifyDeleteRefFailure(ctx context.Context, refName string, expectedOID string, output []byte, updateErr error) error {
- baseErr := fmt.Errorf("failed to delete ref %s: %s: %w", refName, strings.TrimSpace(string(output)), updateErr)
-
- exists, currentOID, stateErr := refStateCLI(ctx, refName)
- if stateErr != nil {
- return baseErr
- }
- if !exists {
- return fmt.Errorf("%w: %s", ErrRefNotFound, refName)
- }
- if expectedOID != "" && currentOID != expectedOID {
- return fmt.Errorf("%w: %s (expected %s)", ErrRefChanged, refName, expectedOID)
- }
-
- return baseErr
-}
-
-func refStateCLI(ctx context.Context, refName string) (exists bool, oid string, err error) {
- check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", refName)
- if err := check.Run(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
- return false, "", nil
- }
- return false, "", fmt.Errorf("failed to check ref %s: %w", refName, err)
- }
-
- cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", refName)
- output, err := cmd.CombinedOutput()
- if err != nil {
- return false, "", fmt.Errorf("failed to resolve ref %s: %s: %w", refName, strings.TrimSpace(string(output)), err)
- }
-
- return true, strings.TrimSpace(string(output)), nil
-}
-
-// branchExistsCLI checks if a branch exists using git CLI.
-// Returns nil if the branch exists, or an error if it does not.
-func branchExistsCLI(ctx context.Context, branchName string) error {
- cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("branch %s not found: %w", branchName, err)
- }
- return nil
-}
-
-// HardResetWithProtection performs a git reset --hard to the specified commit.
-// Uses the git CLI instead of go-git because go-git's HardReset incorrectly
-// deletes untracked directories (like .trace/) even when they're in .gitignore.
-// Returns the short commit ID (7 chars) on success for display purposes.
-func HardResetWithProtection(ctx context.Context, commitHash plumbing.Hash) (shortID string, err error) {
- hashStr := commitHash.String()
- cmd := exec.CommandContext(ctx, "git", "reset", "--hard", hashStr)
- if output, err := cmd.CombinedOutput(); err != nil {
- return "", fmt.Errorf("reset failed: %s: %w", strings.TrimSpace(string(output)), err)
- }
-
- // Return short commit ID for display
- shortID = hashStr
- if len(shortID) > 7 {
- shortID = shortID[:7]
- }
- return shortID, nil
-}
-
-// collectUntrackedFiles collects untracked files in the working directory that are
-// NOT ignored by .gitignore. This is used to capture the initial state when starting
-// a session, ensuring untracked files present at session start are preserved during rewind.
-// Uses "git ls-files --others --exclude-standard -z" to respect .gitignore rules,
-// avoiding bloated session state from large ignored directories like node_modules/.
-// Returns paths relative to the repository root.
-func collectUntrackedFiles(ctx context.Context) ([]string, error) {
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- repoRoot = "."
- }
-
- cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard", "-z")
- cmd.Dir = repoRoot
- output, err := cmd.Output()
- if err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- return nil, fmt.Errorf("git ls-files failed: %s: %w", strings.TrimSpace(string(exitErr.Stderr)), err)
- }
- return nil, fmt.Errorf("git ls-files failed: %w", err)
- }
-
- raw := string(output)
- if raw == "" {
- return nil, nil
- }
-
- var files []string
- for _, f := range strings.Split(raw, "\x00") {
- // Defense-in-depth: filter protected paths even though --exclude-standard should already handle them
- if f != "" && !isProtectedPath(f) {
- files = append(files, f)
- }
- }
- return files, nil
-}
-
-// ExtractSessionIDFromCommit extracts the session ID from a commit's trailers.
-// It checks the Trace-Session trailer first, then falls back to extracting from
-// the metadata directory path in the Trace-Metadata trailer.
-// Returns empty string if no session ID is found.
-func ExtractSessionIDFromCommit(commit *object.Commit) string {
- // Try Trace-Session trailer first
- if sessionID, found := trailers.ParseSession(commit.Message); found {
- return sessionID
- }
-
- // Try extracting from metadata directory (last path component)
- if metadataDir, found := trailers.ParseMetadata(commit.Message); found {
- return filepath.Base(metadataDir)
- }
-
- return ""
-}
-
-// NOTE: The following git tree helper functions have been moved to checkpoint/ package:
-// - FlattenTree -> checkpoint.FlattenTree
-// - CreateBlobFromContent -> checkpoint.CreateBlobFromContent
-// - BuildTreeFromEntries -> checkpoint.BuildTreeFromEntries
-// - sortTreeEntries (internal to checkpoint package)
-// - treeNode, insertIntoTree, buildTreeObject (internal to checkpoint package)
-//
-// See push_common.go and session_test.go for usage examples.
-
-// getSessionDescriptionFromTree reads the first line of prompt.txt from a git tree.
-// This is the tree-based equivalent of getSessionDescription (which reads from filesystem).
-//
-// If metadataDir is provided, looks for files at metadataDir/prompt.txt.
-// If metadataDir is empty, first tries the root of the tree (for when the tree is already
-// the session directory), then falls back to
-// searching for .trace/metadata/*/prompt.txt (for full worktree trees).
-func getSessionDescriptionFromTree(tree *object.Tree, metadataDir string) string {
- // Helper to read first line from a file in tree
- readFirstLine := func(path string) string {
- file, err := tree.File(path)
- if err != nil {
- return ""
- }
- content, err := file.Contents()
- if err != nil {
- return ""
- }
- lines := strings.SplitN(content, "\n", 2)
- if len(lines) > 0 && lines[0] != "" {
- return strings.TrimSpace(lines[0])
- }
- return ""
- }
-
- // If metadataDir is provided, look there directly
- if metadataDir != "" {
- if desc := readFirstLine(metadataDir + "/" + paths.PromptFileName); desc != "" {
- return desc
- }
- return NoDescription
- }
-
- // No metadataDir provided - first try looking at the root of the tree
- // (used when the tree is already the session directory)
- if desc := readFirstLine(paths.PromptFileName); desc != "" {
- return desc
- }
-
- // Fall back to searching for .trace/metadata/*/prompt.txt
- // (used when the tree is the full worktree)
- var desc string
- //nolint:errcheck // We ignore errors here as we're just searching for a description
- _ = tree.Files().ForEach(func(f *object.File) error {
- if desc != "" {
- return nil // Already found description
- }
- name := f.Name
- if strings.Contains(name, ".trace/metadata/") && strings.HasSuffix(name, "/"+paths.PromptFileName) {
- content, err := f.Contents()
- if err != nil {
- return nil //nolint:nilerr // Skip files we can't read, continue searching
- }
- lines := strings.SplitN(content, "\n", 2)
- if len(lines) > 0 && lines[0] != "" {
- desc = strings.TrimSpace(lines[0])
- }
- }
- return nil
- })
-
- if desc != "" {
- return desc
- }
- return NoDescription
-}
-
-// GetGitAuthorFromRepo retrieves the git user.name and user.email,
-// checking both the repository-local config and the global ~/.gitconfig.
-// Delegates to checkpoint.GetGitAuthorFromRepo — this wrapper exists so
-// callers within the strategy package don't need a qualified import.
-func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
- return checkpoint.GetGitAuthorFromRepo(repo)
-}
-
-// GetCurrentBranchName returns the short name of the current branch if HEAD points to a branch.
-// Returns an empty string if in detached HEAD state or if there's an error reading HEAD.
-// This is used to capture branch metadata for checkpoints.
-func GetCurrentBranchName(repo *git.Repository) string {
- head, err := repo.Head()
- if err != nil || !head.Name().IsBranch() {
- return ""
- }
- return head.Name().Short()
-}
-
-// getMainBranchHash returns the hash of the main branch (main or master).
-// Returns ZeroHash if no main branch is found.
-func GetMainBranchHash(repo *git.Repository) plumbing.Hash {
- // Try common main branch names
- for _, branchName := range []string{branchMain, branchMaster} {
- // Try local branch first
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- if err == nil {
- return ref.Hash()
- }
- // Try remote tracking branch
- ref, err = repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true)
- if err == nil {
- return ref.Hash()
- }
- }
- return plumbing.ZeroHash
-}
-
-// GetDefaultBranchName returns the name of the default branch.
-// First checks origin/HEAD, then falls back to checking if main/master exists.
-// Returns empty string if unable to determine.
-// NOTE: Duplicated from cli/git_operations.go - see ENT-129 for consolidation.
-func GetDefaultBranchName(repo *git.Repository) string {
- // Try to get the symbolic reference for origin/HEAD
- // Use resolved=false to get the symbolic ref itself, then extract its target
- ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), false)
- if err == nil && ref != nil && ref.Type() == plumbing.SymbolicReference {
- target := ref.Target().String()
- if branchName, found := strings.CutPrefix(target, "refs/remotes/origin/"); found {
- return branchName
- }
- }
-
- // Fallback: check if origin/main or origin/master exists
- if _, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchMain), true); err == nil {
- return branchMain
- }
- if _, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchMaster), true); err == nil {
- return branchMaster
- }
-
- // Final fallback: check local branches
- if _, err := repo.Reference(plumbing.NewBranchReferenceName(branchMain), true); err == nil {
- return branchMain
- }
- if _, err := repo.Reference(plumbing.NewBranchReferenceName(branchMaster), true); err == nil {
- return branchMaster
- }
-
- return ""
-}
-
-// IsOnDefaultBranch checks if the repository HEAD is on the default branch.
-// Returns (isOnDefault, currentBranchName).
-// NOTE: Duplicated from cli/git_operations.go - see ENT-129 for consolidation.
-func IsOnDefaultBranch(repo *git.Repository) (bool, string) {
- currentBranch := GetCurrentBranchName(repo)
- if currentBranch == "" {
- return false, ""
- }
-
- defaultBranch := GetDefaultBranchName(repo)
- if defaultBranch == "" {
- // Can't determine default, check common names
- if currentBranch == branchMain || currentBranch == branchMaster {
- return true, currentBranch
- }
- return false, currentBranch
- }
-
- return currentBranch == defaultBranch, currentBranch
-}
-
-// prepareTranscriptForState ensures the transcript is up-to-date for the given session.
-// Only prepares for ACTIVE sessions — IDLE/ENDED sessions are already flushed.
-// Resolves the agent from state.AgentType internally. Multiple calls are safe but
-// not free — callers should avoid redundant calls for performance.
-func prepareTranscriptForState(ctx context.Context, state *SessionState) {
- if !state.Phase.IsActive() || state.TranscriptPath == "" || state.AgentType == "" {
- return
- }
- ag, err := agent.GetByAgentType(state.AgentType)
- if err != nil {
- logging.Debug(
- ctx, "prepareTranscriptForState: unknown agent type",
- slog.String("session_id", state.SessionID),
- slog.String("agent_type", string(state.AgentType)),
- slog.Any("error", err),
- )
- return
- }
- prepareTranscriptIfNeeded(ctx, ag, state.TranscriptPath)
-}
-
-// prepareTranscriptIfNeeded calls PrepareTranscript for agents that implement
-// the TranscriptPreparer interface. This ensures transcript files exist before
-// they are read (e.g., OpenCode creates its transcript lazily via `opencode export`).
-// Errors are silently ignored — this is best-effort for hook paths.
-func prepareTranscriptIfNeeded(ctx context.Context, ag agent.Agent, transcriptPath string) {
- if ag == nil || transcriptPath == "" {
- return
- }
- if preparer, ok := agent.AsTranscriptPreparer(ag); ok {
- // Best-effort: callers handle missing files gracefully.
- // Transcript may not be available yet (e.g., agent not installed).
- _ = preparer.PrepareTranscript(ctx, transcriptPath) //nolint:errcheck // Best-effort in hook path
- }
-}
diff --git a/cli/strategy/common_2.go b/cli/strategy/common_2.go
new file mode 100644
index 0000000..92e021e
--- /dev/null
+++ b/cli/strategy/common_2.go
@@ -0,0 +1,782 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// isOnlySeparators checks if a string contains only dashes, spaces, and newlines.
+func isOnlySeparators(s string) bool {
+ for _, r := range s {
+ if r != '-' && r != ' ' && r != '\n' && r != '\r' && r != '\t' {
+ return false
+ }
+ }
+ return true
+}
+
+// ReadLatestSessionPromptFromCommittedTree reads the first prompt from a committed checkpoint's
+// latest session on the metadata branch tree. This navigates the sharded directory layout:
+// //prompt.txt
+//
+// Falls back through earlier sessions when the latest has no prompt.
+// Avoids reading full transcripts — only reads prompt.txt files.
+// sessionCount is the number of sessions in the checkpoint (from CommittedInfo.SessionCount).
+func ReadLatestSessionPromptFromCommittedTree(tree *object.Tree, cpID id.CheckpointID, sessionCount int) string {
+ cpPath := cpID.Path()
+ cpTree, err := tree.Tree(cpPath)
+ if err != nil {
+ return ""
+ }
+
+ // Find the latest session subdirectory with a prompt.
+ // Sessions use 0-based indexing: 0/, 1/, 2/, etc.
+ // Start from the latest and fall back through earlier sessions
+ // when the latest has no prompt (e.g. a test or empty session was
+ // condensed alongside a real one).
+ latestIndex := max(sessionCount-1, 0)
+
+ for i := latestIndex; i >= 0; i-- {
+ sessionPath := strconv.Itoa(i)
+ sessionTree, err := cpTree.Tree(sessionPath)
+ if err != nil {
+ continue
+ }
+
+ file, err := sessionTree.File(paths.PromptFileName)
+ if err != nil {
+ continue
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ continue
+ }
+
+ if prompt := ExtractFirstPrompt(content); prompt != "" {
+ return prompt
+ }
+ }
+
+ return ""
+}
+
+// ReadAllSessionPromptsFromTree reads the first prompt for all sessions in a multi-session checkpoint.
+// Returns a slice of prompts parallel to sessionIDs (oldest to newest).
+// For single-session checkpoints, returns a slice with just the root prompt.
+func ReadAllSessionPromptsFromTree(tree *object.Tree, checkpointPath string, sessionCount int, sessionIDs []string) []string {
+ if sessionCount <= 1 || len(sessionIDs) <= 1 {
+ // Single session - just return the root prompt
+ prompt := ReadSessionPromptFromTree(tree, checkpointPath)
+ if prompt != "" {
+ return []string{prompt}
+ }
+ return nil
+ }
+
+ // Multi-session: read prompts from archived folders (0/, 1/, etc.) and root
+ prompts := make([]string, len(sessionIDs))
+
+ // Read archived session prompts (folders 0, 1, ... N-2)
+ for i := range sessionCount - 1 {
+ archivedPath := fmt.Sprintf("%s/%d", checkpointPath, i)
+ prompts[i] = ReadSessionPromptFromTree(tree, archivedPath)
+ }
+
+ // Read the most recent session prompt (at root level)
+ prompts[len(prompts)-1] = ReadSessionPromptFromTree(tree, checkpointPath)
+
+ return prompts
+}
+
+// GetRemoteMetadataBranchTree returns the tree object for origin/trace/checkpoints/v1.
+func GetRemoteMetadataBranchTree(repo *git.Repository) (*object.Tree, error) {
+ refName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get remote metadata branch reference: %w", err)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get remote metadata branch commit: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get remote metadata branch tree: %w", err)
+ }
+ return tree, nil
+}
+
+// OpenRepository opens the git repository from the repo root.
+// Each call returns a fresh instance to avoid storer contention between
+// concurrent goroutines — go-git's filesystem storer is not safe for
+// concurrent read+write even across separate Repository instances that
+// share the same .git directory.
+func OpenRepository(ctx context.Context) (*git.Repository, error) {
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ // Fallback to current directory if git command fails
+ // (e.g., if git is not installed or we're not in a repo)
+ repoRoot = "."
+ }
+
+ repo, err := git.PlainOpen(repoRoot)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open repository: %w", err)
+ }
+ return repo, nil
+}
+
+// IsInsideWorktree returns true if the current directory is inside a git worktree
+// (as opposed to the main repository). Worktrees have .git as a file pointing
+// to the main repo, while the main repo has .git as a directory.
+// This function works correctly from any subdirectory within the repository.
+func IsInsideWorktree(ctx context.Context) bool {
+ // First find the repository root
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return false
+ }
+
+ gitPath := filepath.Join(repoRoot, gitDir)
+ gitInfo, err := os.Stat(gitPath)
+ if err != nil {
+ return false
+ }
+ return !gitInfo.IsDir()
+}
+
+// GetMainRepoRoot returns the root directory of the main repository.
+// In the main repo, this is the worktree path (repo root).
+// In a worktree, this parses the .git file to find the main repo.
+// This function works correctly from any subdirectory within the repository.
+//
+// Per gitrepository-layout(5), a worktree's .git file is a "gitfile" containing
+// "gitdir: " pointing to $GIT_DIR/worktrees/ in the main repository.
+// See: https://git-scm.com/docs/gitrepository-layout
+func GetMainRepoRoot(ctx context.Context) (string, error) {
+ // First find the worktree/repo root
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to get worktree path: %w", err)
+ }
+
+ if !IsInsideWorktree(ctx) {
+ return repoRoot, nil
+ }
+
+ // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/"
+ gitFilePath := filepath.Join(repoRoot, gitDir)
+ content, err := os.ReadFile(gitFilePath) //nolint:gosec // G304: gitFilePath is constructed from repo root, not user input
+ if err != nil {
+ return "", fmt.Errorf("failed to read .git file: %w", err)
+ }
+
+ gitdir := strings.TrimSpace(string(content))
+ gitdir = strings.TrimPrefix(gitdir, "gitdir: ")
+
+ // Extract main repo root: everything before "/.git/"
+ idx := strings.LastIndex(gitdir, "/.git/")
+ if idx < 0 {
+ return "", fmt.Errorf("unexpected gitdir format: %s", gitdir)
+ }
+ return gitdir[:idx], nil
+}
+
+// GetGitCommonDir returns the path to the shared git directory.
+// In a regular checkout, this is .git/
+// In a worktree, this is the main repo's .git/ (not .git/worktrees//)
+// Uses git rev-parse --git-common-dir for reliable handling of worktrees.
+func GetGitCommonDir(ctx context.Context) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
+ cmd.Dir = "."
+ output, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to get git common dir: %w", err)
+ }
+
+ commonDir := strings.TrimSpace(string(output))
+
+ // git rev-parse --git-common-dir returns relative paths from the working directory,
+ // so we need to make it absolute if it isn't already
+ if !filepath.IsAbs(commonDir) {
+ commonDir = filepath.Join(".", commonDir)
+ }
+
+ return filepath.Clean(commonDir), nil
+}
+
+// EnsureTraceGitignore ensures all required entries are in .trace/.gitignore
+// Works correctly from any subdirectory within the repository.
+func EnsureTraceGitignore(ctx context.Context) error {
+ // Get absolute path for the gitignore file
+ gitignoreAbs, err := paths.AbsPath(ctx, traceGitignore)
+ if err != nil {
+ gitignoreAbs = traceGitignore // Fallback to relative
+ }
+
+ // Read existing content
+ var content string
+ if data, err := os.ReadFile(gitignoreAbs); err == nil { //nolint:gosec // path is from AbsPath or constant
+ content = string(data)
+ }
+
+ // All entries that should be in .trace/.gitignore
+ requiredEntries := []string{
+ "tmp/",
+ "settings.local.json",
+ "metadata/",
+ "logs/",
+ }
+
+ // Track what needs to be added
+ var toAdd []string
+ for _, entry := range requiredEntries {
+ if !strings.Contains(content, entry) {
+ toAdd = append(toAdd, entry)
+ }
+ }
+
+ // Nothing to add
+ if len(toAdd) == 0 {
+ return nil
+ }
+
+ // Ensure .trace directory exists
+ if err := os.MkdirAll(filepath.Dir(gitignoreAbs), 0o750); err != nil {
+ return fmt.Errorf("failed to create .trace directory: %w", err)
+ }
+
+ // Append missing entries to gitignore
+ var sb strings.Builder
+ for _, entry := range toAdd {
+ sb.WriteString(entry + "\n")
+ }
+ content += sb.String()
+
+ if err := os.WriteFile(gitignoreAbs, []byte(content), 0o644); err != nil { //nolint:gosec // path is from AbsPath or constant
+ return fmt.Errorf("failed to write gitignore: %w", err)
+ }
+ return nil
+}
+
+// checkCanRewindWithWarning checks working directory and returns a warning with diff stats.
+// Always returns canRewind=true but includes a warning message with +/- line stats for
+// uncommitted changes. Used by manual-commit strategy.
+func checkCanRewindWithWarning(ctx context.Context) (bool, string, error) {
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ // Can't open repo - still allow rewind but without stats
+ return true, "", nil //nolint:nilerr // Rewind allowed even if repo can't be opened
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even if worktree can't be accessed
+ }
+
+ status, err := worktree.Status()
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even if status can't be retrieved
+ }
+
+ if status.IsClean() {
+ return true, "", nil
+ }
+
+ // Get HEAD commit tree for comparison - if we can't get it, just return without stats
+ head, err := repo.Head()
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even without HEAD (e.g., empty repo)
+ }
+
+ headCommit, err := repo.CommitObject(head.Hash())
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even if commit lookup fails
+ }
+
+ headTree, err := headCommit.Tree()
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even if tree lookup fails
+ }
+
+ type fileChange struct {
+ status string // "modified", "added", "deleted"
+ added int
+ removed int
+ filename string
+ }
+
+ var changes []fileChange
+ // Use repo root, not cwd - git status returns paths relative to repo root
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return true, "", nil //nolint:nilerr // Rewind allowed even if worktree root lookup fails
+ }
+
+ for file, st := range status {
+ // Skip .trace directory
+ if paths.IsInfrastructurePath(file) {
+ continue
+ }
+
+ // Skip untracked files
+ if st.Worktree == git.Untracked {
+ continue
+ }
+
+ var change fileChange
+ change.filename = file
+
+ switch {
+ case st.Staging == git.Added || st.Worktree == git.Added:
+ change.status = "added"
+ // New file - count all lines as added
+ absPath := filepath.Join(repoRoot, file)
+ if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status
+ change.added = countLines(content)
+ }
+ case st.Staging == git.Deleted || st.Worktree == git.Deleted:
+ change.status = "deleted"
+ // Deleted file - count lines from HEAD as removed
+ if entry, err := headTree.File(file); err == nil {
+ if content, err := entry.Contents(); err == nil {
+ change.removed = countLines([]byte(content))
+ }
+ }
+ case st.Staging == git.Modified || st.Worktree == git.Modified:
+ change.status = "modified"
+ // Modified file - compute diff stats
+ var headContent, workContent []byte
+ if entry, err := headTree.File(file); err == nil {
+ if content, err := entry.Contents(); err == nil {
+ headContent = []byte(content)
+ }
+ }
+ absPath := filepath.Join(repoRoot, file)
+ if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status
+ workContent = content
+ }
+ if headContent != nil && workContent != nil {
+ change.added, change.removed = computeDiffStats(headContent, workContent)
+ }
+ default:
+ continue
+ }
+
+ changes = append(changes, change)
+ }
+
+ if len(changes) == 0 {
+ return true, "", nil
+ }
+
+ // Sort changes by filename for consistent output
+ sort.Slice(changes, func(i, j int) bool {
+ return changes[i].filename < changes[j].filename
+ })
+
+ var msg strings.Builder
+ msg.WriteString("The following uncommitted changes will be reverted:\n")
+
+ totalAdded, totalRemoved := 0, 0
+ for _, c := range changes {
+ totalAdded += c.added
+ totalRemoved += c.removed
+
+ var stats string
+ switch {
+ case c.added > 0 && c.removed > 0:
+ stats = fmt.Sprintf("+%d/-%d", c.added, c.removed)
+ case c.added > 0:
+ stats = fmt.Sprintf("+%d", c.added)
+ case c.removed > 0:
+ stats = fmt.Sprintf("-%d", c.removed)
+ }
+
+ fmt.Fprintf(&msg, " %-10s %s", c.status+":", c.filename)
+ if stats != "" {
+ fmt.Fprintf(&msg, " (%s)", stats)
+ }
+ msg.WriteString("\n")
+ }
+
+ if totalAdded > 0 || totalRemoved > 0 {
+ fmt.Fprintf(&msg, "\nTotal: +%d/-%d lines\n", totalAdded, totalRemoved)
+ }
+
+ return true, msg.String(), nil
+}
+
+// countLines counts the number of lines in content.
+func countLines(content []byte) int {
+ if len(content) == 0 {
+ return 0
+ }
+ count := 1
+ for _, b := range content {
+ if b == '\n' {
+ count++
+ }
+ }
+ // Don't count trailing newline as extra line
+ if len(content) > 0 && content[len(content)-1] == '\n' {
+ count--
+ }
+ return count
+}
+
+// computeDiffStats computes added and removed line counts between old and new content.
+// Uses a simple line-based diff algorithm.
+func computeDiffStats(oldContent, newContent []byte) (added, removed int) {
+ oldLines := splitLines(oldContent)
+ newLines := splitLines(newContent)
+
+ // Build a set of old lines with counts
+ oldSet := make(map[string]int)
+ for _, line := range oldLines {
+ oldSet[line]++
+ }
+
+ // Check which new lines are truly new
+ for _, line := range newLines {
+ if oldSet[line] > 0 {
+ oldSet[line]--
+ } else {
+ added++
+ }
+ }
+
+ // Remaining old lines are removed
+ for _, count := range oldSet {
+ removed += count
+ }
+
+ return added, removed
+}
+
+// splitLines splits content into lines, preserving empty lines.
+// Handles both Unix (\n) and Windows (\r\n) line endings.
+func splitLines(content []byte) []string {
+ if len(content) == 0 {
+ return nil
+ }
+ s := string(content)
+ // Normalize Windows line endings to Unix
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ // Remove trailing newline to avoid empty last element
+ s = strings.TrimSuffix(s, "\n")
+ return strings.Split(s, "\n")
+}
+
+// fileExists checks if a file exists at the given path.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// getTaskCheckpointFromTree retrieves a task checkpoint from a commit tree.
+// Shared implementation for shadow and linear-shadow strategies.
+func getTaskCheckpointFromTree(ctx context.Context, point RewindPoint) (*TaskCheckpoint, error) {
+ if !point.IsTaskCheckpoint {
+ return nil, ErrNotTaskCheckpoint
+ }
+
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open repository: %w", err)
+ }
+
+ commitHash := plumbing.NewHash(point.ID)
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get tree: %w", err)
+ }
+
+ // Read checkpoint.json from the tree
+ checkpointPath := point.MetadataDir + "/checkpoint.json"
+ file, err := tree.File(checkpointPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find checkpoint at %s: %w", checkpointPath, err)
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read checkpoint: %w", err)
+ }
+
+ var checkpoint TaskCheckpoint
+ if err := json.Unmarshal([]byte(content), &checkpoint); err != nil {
+ return nil, fmt.Errorf("failed to parse checkpoint: %w", err)
+ }
+
+ return &checkpoint, nil
+}
+
+// getTaskTranscriptFromTree retrieves a task transcript from a commit tree.
+// Shared implementation for shadow and linear-shadow strategies.
+func getTaskTranscriptFromTree(ctx context.Context, point RewindPoint) ([]byte, error) {
+ if !point.IsTaskCheckpoint {
+ return nil, ErrNotTaskCheckpoint
+ }
+
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open repository: %w", err)
+ }
+
+ commitHash := plumbing.NewHash(point.ID)
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get tree: %w", err)
+ }
+
+ // MetadataDir format: .trace/metadata//tasks/
+ // Session transcript is at: .trace/metadata//
+ sessionDir := filepath.Dir(filepath.Dir(point.MetadataDir))
+
+ // Try current format first, then legacy
+ transcriptPath := sessionDir + "/" + paths.TranscriptFileName
+ file, err := tree.File(transcriptPath)
+ if err != nil {
+ transcriptPath = sessionDir + "/" + paths.TranscriptFileNameLegacy
+ file, err = tree.File(transcriptPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find transcript: %w", err)
+ }
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read transcript: %w", err)
+ }
+
+ return []byte(content), nil
+}
+
+// ErrBranchNotFound is returned by DeleteBranchCLI when the branch does not exist.
+var ErrBranchNotFound = errors.New("branch not found")
+
+// ErrRefNotFound is returned by DeleteRefCLI when the ref does not exist.
+var ErrRefNotFound = errors.New("ref not found")
+
+// ErrRefChanged is returned by DeleteRefCLI when the ref no longer points to the expected OID.
+var ErrRefChanged = errors.New("ref changed since inspection")
+
+// DeleteBranchCLI deletes a git branch using the git CLI.
+// Uses `git branch -D` instead of go-git's RemoveReference because go-git v5
+// doesn't properly persist deletions when refs are packed (.git/packed-refs)
+// or in a worktree context. This is the same class of go-git v5 bug that
+// affects checkout and reset --hard (see HardResetWithProtection).
+//
+// Returns ErrBranchNotFound if the branch does not exist, allowing callers
+// to use errors.Is for idempotent deletion patterns.
+func DeleteBranchCLI(ctx context.Context, branchName string) error {
+ // Pre-check: verify the branch exists so callers get a structured error
+ // instead of parsing git's output string (which varies across locales).
+ // git show-ref exits 1 for "not found" and 128+ for fatal errors (corrupt
+ // repo, permissions, not a git directory). Only map exit code 1 to
+ // ErrBranchNotFound; propagate other failures as-is.
+ check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
+ if err := check.Run(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
+ return fmt.Errorf("%w: %s", ErrBranchNotFound, branchName)
+ }
+ return fmt.Errorf("failed to check branch %s: %w", branchName, err)
+ }
+
+ cmd := exec.CommandContext(ctx, "git", "branch", "-D", "--", branchName)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to delete branch %s: %s: %w", branchName, strings.TrimSpace(string(output)), err)
+ }
+ return nil
+}
+
+// DeleteRefCLI deletes an arbitrary ref using the git CLI.
+// Uses `git update-ref -d` instead of go-git's RemoveReference because go-git
+// ref deletion is unreliable with packed refs and worktrees.
+//
+// When expectedOID is non-empty, it is passed to `git update-ref -d ][ `
+// as a compare-and-swap guard: git will refuse the deletion if the ref no longer
+// points to expectedOID, and ErrRefChanged is returned.
+//
+// Returns ErrRefNotFound if the ref does not exist, allowing callers to use
+// errors.Is for idempotent deletion patterns.
+func DeleteRefCLI(ctx context.Context, refName string, expectedOID string) error {
+ exists, _, err := refStateCLI(ctx, refName)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return fmt.Errorf("%w: %s", ErrRefNotFound, refName)
+ }
+
+ args := []string{"update-ref", "-d", refName}
+ if expectedOID != "" {
+ args = append(args, expectedOID)
+ }
+ cmd := exec.CommandContext(ctx, "git", args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return classifyDeleteRefFailure(ctx, refName, expectedOID, output, err)
+ }
+ return nil
+}
+
+func classifyDeleteRefFailure(ctx context.Context, refName string, expectedOID string, output []byte, updateErr error) error {
+ baseErr := fmt.Errorf("failed to delete ref %s: %s: %w", refName, strings.TrimSpace(string(output)), updateErr)
+
+ exists, currentOID, stateErr := refStateCLI(ctx, refName)
+ if stateErr != nil {
+ return baseErr
+ }
+ if !exists {
+ return fmt.Errorf("%w: %s", ErrRefNotFound, refName)
+ }
+ if expectedOID != "" && currentOID != expectedOID {
+ return fmt.Errorf("%w: %s (expected %s)", ErrRefChanged, refName, expectedOID)
+ }
+
+ return baseErr
+}
+
+func refStateCLI(ctx context.Context, refName string) (exists bool, oid string, err error) {
+ check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", refName)
+ if err := check.Run(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
+ return false, "", nil
+ }
+ return false, "", fmt.Errorf("failed to check ref %s: %w", refName, err)
+ }
+
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", refName)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return false, "", fmt.Errorf("failed to resolve ref %s: %s: %w", refName, strings.TrimSpace(string(output)), err)
+ }
+
+ return true, strings.TrimSpace(string(output)), nil
+}
+
+// branchExistsCLI checks if a branch exists using git CLI.
+// Returns nil if the branch exists, or an error if it does not.
+func branchExistsCLI(ctx context.Context, branchName string) error {
+ cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("branch %s not found: %w", branchName, err)
+ }
+ return nil
+}
+
+// HardResetWithProtection performs a git reset --hard to the specified commit.
+// Uses the git CLI instead of go-git because go-git's HardReset incorrectly
+// deletes untracked directories (like .trace/) even when they're in .gitignore.
+// Returns the short commit ID (7 chars) on success for display purposes.
+func HardResetWithProtection(ctx context.Context, commitHash plumbing.Hash) (shortID string, err error) {
+ hashStr := commitHash.String()
+ cmd := exec.CommandContext(ctx, "git", "reset", "--hard", hashStr)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return "", fmt.Errorf("reset failed: %s: %w", strings.TrimSpace(string(output)), err)
+ }
+
+ // Return short commit ID for display
+ shortID = hashStr
+ if len(shortID) > 7 {
+ shortID = shortID[:7]
+ }
+ return shortID, nil
+}
+
+// collectUntrackedFiles collects untracked files in the working directory that are
+// NOT ignored by .gitignore. This is used to capture the initial state when starting
+// a session, ensuring untracked files present at session start are preserved during rewind.
+// Uses "git ls-files --others --exclude-standard -z" to respect .gitignore rules,
+// avoiding bloated session state from large ignored directories like node_modules/.
+// Returns paths relative to the repository root.
+func collectUntrackedFiles(ctx context.Context) ([]string, error) {
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ repoRoot = "."
+ }
+
+ cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard", "-z")
+ cmd.Dir = repoRoot
+ output, err := cmd.Output()
+ if err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return nil, fmt.Errorf("git ls-files failed: %s: %w", strings.TrimSpace(string(exitErr.Stderr)), err)
+ }
+ return nil, fmt.Errorf("git ls-files failed: %w", err)
+ }
+
+ raw := string(output)
+ if raw == "" {
+ return nil, nil
+ }
+
+ var files []string
+ for _, f := range strings.Split(raw, "\x00") {
+ // Defense-in-depth: filter protected paths even though --exclude-standard should already handle them
+ if f != "" && !isProtectedPath(f) {
+ files = append(files, f)
+ }
+ }
+ return files, nil
+}
+
+// ExtractSessionIDFromCommit extracts the session ID from a commit's trailers.
+// It checks the Trace-Session trailer first, then falls back to extracting from
+// the metadata directory path in the Trace-Metadata trailer.
+// Returns empty string if no session ID is found.
+func ExtractSessionIDFromCommit(commit *object.Commit) string {
+ // Try Trace-Session trailer first
+ if sessionID, found := trailers.ParseSession(commit.Message); found {
+ return sessionID
+ }
+
+ // Try extracting from metadata directory (last path component)
+ if metadataDir, found := trailers.ParseMetadata(commit.Message); found {
+ return filepath.Base(metadataDir)
+ }
+
+ return ""
+}
+
+// NOTE: The following git tree helper functions have been moved to checkpoint/ package:
+// - FlattenTree -> checkpoint.FlattenTree
+// - CreateBlobFromContent -> checkpoint.CreateBlobFromContent
+// - BuildTreeFromEntries -> checkpoint.BuildTreeFromEntries
+// - sortTreeEntries (internal to checkpoint package)
+// - treeNode, insertIntoTree, buildTreeObject (internal to checkpoint package)
+//
+// See push_common.go and session_test.go for usage examples.
diff --git a/cli/strategy/common_2_test.go b/cli/strategy/common_2_test.go
new file mode 100644
index 0000000..7365ac4
--- /dev/null
+++ b/cli/strategy/common_2_test.go
@@ -0,0 +1,811 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/vercelconfig"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetGitAuthorFromRepo(t *testing.T) {
+ // Cannot use t.Parallel() because subtests use t.Setenv to isolate global git config.
+
+ tests := []struct {
+ name string
+ localName string
+ localEmail string
+ globalName string
+ globalEmail string
+ wantName string
+ wantEmail string
+ }{
+ {
+ name: "both set locally",
+ localName: "Local User",
+ localEmail: "local@example.com",
+ wantName: "Local User",
+ wantEmail: "local@example.com",
+ },
+ {
+ name: "only name set locally falls back to global for email",
+ localName: "Local User",
+ globalEmail: "global@example.com",
+ wantName: "Local User",
+ wantEmail: "global@example.com",
+ },
+ {
+ name: "only email set locally falls back to global for name",
+ localEmail: "local@example.com",
+ globalName: "Global User",
+ wantName: "Global User",
+ wantEmail: "local@example.com",
+ },
+ {
+ name: "nothing set locally falls back to global for both",
+ globalName: "Global User",
+ globalEmail: "global@example.com",
+ wantName: "Global User",
+ wantEmail: "global@example.com",
+ },
+ {
+ name: "nothing set anywhere returns defaults",
+ wantName: "Unknown",
+ wantEmail: "unknown@local",
+ },
+ {
+ name: "local takes precedence over global",
+ localName: "Local User",
+ localEmail: "local@example.com",
+ globalName: "Global User",
+ globalEmail: "global@example.com",
+ wantName: "Local User",
+ wantEmail: "local@example.com",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ useAutoConfigLoader(t)
+
+ // Isolate global git config by pointing HOME to a temp dir
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", "")
+
+ // Write global .gitconfig if needed
+ if tt.globalName != "" || tt.globalEmail != "" {
+ globalCfg := "[user]\n"
+ if tt.globalName != "" {
+ globalCfg += "\tname = " + tt.globalName + "\n"
+ }
+ if tt.globalEmail != "" {
+ globalCfg += "\temail = " + tt.globalEmail + "\n"
+ }
+ if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalCfg), 0o644); err != nil {
+ t.Fatalf("failed to write global gitconfig: %v", err)
+ }
+ }
+
+ // Create a repo for config resolution
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ // Set local config if needed
+ if tt.localName != "" || tt.localEmail != "" {
+ cfg, err := repo.Config()
+ if err != nil {
+ t.Fatalf("failed to get repo config: %v", err)
+ }
+ cfg.User.Name = tt.localName
+ cfg.User.Email = tt.localEmail
+ if err := repo.SetConfig(cfg); err != nil {
+ t.Fatalf("failed to set repo config: %v", err)
+ }
+ }
+
+ gotName, gotEmail := GetGitAuthorFromRepo(repo)
+ if gotName != tt.wantName {
+ t.Errorf("name = %q, want %q", gotName, tt.wantName)
+ }
+ if gotEmail != tt.wantEmail {
+ t.Errorf("email = %q, want %q", gotEmail, tt.wantEmail)
+ }
+ })
+ }
+}
+
+func TestIsProtectedPath(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ path string
+ protected bool
+ }{
+ {".git", true},
+ {".git/objects", true},
+ {".trace", true},
+ {".trace/metadata/session.json", true},
+ {".claude", true},
+ {".claude/settings.json", true},
+ {".gemini", true},
+ {".gemini/settings.json", true},
+ {"src/main.go", false},
+ {"README.md", false},
+ {".gitignore", false},
+ {".github/workflows/ci.yml", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ t.Parallel()
+ if got := isProtectedPath(tt.path); got != tt.protected {
+ t.Errorf("isProtectedPath(%q) = %v, want %v", tt.path, got, tt.protected)
+ }
+ })
+ }
+}
+
+// initBareWithMetadataBranch creates a bare repo with a main branch and an
+// trace/checkpoints/v1 branch containing checkpoint data via git CLI.
+func initBareWithMetadataBranch(t *testing.T) string {
+ t.Helper()
+ bareDir := t.TempDir()
+
+ // Init bare, create main branch with a commit
+ workDir := t.TempDir()
+ run := func(dir string, args ...string) {
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = dir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ }
+ run(bareDir, "init", "--bare", "-b", "main")
+ run(workDir, "clone", bareDir, ".")
+ run(workDir, "config", "user.email", "test@test.com")
+ run(workDir, "config", "user.name", "Test User")
+ run(workDir, "config", "commit.gpgsign", "false")
+ if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ run(workDir, "add", ".")
+ run(workDir, "commit", "-m", "init")
+ run(workDir, "push", "origin", "main")
+
+ // Create orphan trace/checkpoints/v1 with data
+ run(workDir, "checkout", "--orphan", paths.MetadataBranchName)
+ run(workDir, "rm", "-rf", ".")
+ if err := os.WriteFile(filepath.Join(workDir, "metadata.json"), []byte(`{"checkpoint_id":"test123"}`), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ run(workDir, "add", ".")
+ run(workDir, "commit", "-m", "Checkpoint: test123")
+ run(workDir, "push", "origin", paths.MetadataBranchName)
+
+ return bareDir
+}
+
+func TestEnsureMetadataBranch(t *testing.T) {
+ t.Parallel()
+
+ t.Run("creates from remote on fresh clone", func(t *testing.T) {
+ bareDir := initBareWithMetadataBranch(t)
+ cloneDir := filepath.Join(t.TempDir(), "clone")
+ cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("clone failed: %v\n%s", err, out)
+ }
+
+ repo, err := git.PlainOpen(cloneDir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("EnsureMetadataBranch() failed: %v", err)
+ }
+
+ // Local branch should exist with data (not empty)
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+ if len(tree.Entries) == 0 {
+ t.Error("local branch has empty tree — remote data was not preserved")
+ }
+ })
+
+ t.Run("updates empty orphan from remote", func(t *testing.T) {
+ t.Parallel()
+ bareDir := initBareWithMetadataBranch(t)
+ cloneDir := filepath.Join(t.TempDir(), "clone")
+ cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("clone failed: %v\n%s", err, out)
+ }
+
+ repo, err := git.PlainOpen(cloneDir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+
+ // Create an empty orphan locally (simulates old enable behavior)
+ emptyTree := &object.Tree{Entries: []object.TreeEntry{}}
+ treeObj := repo.Storer.NewEncodedObject()
+ if err := emptyTree.Encode(treeObj); err != nil {
+ t.Fatalf("failed to encode tree: %v", err)
+ }
+ treeHash, err := repo.Storer.SetEncodedObject(treeObj)
+ if err != nil {
+ t.Fatalf("failed to store tree: %v", err)
+ }
+ orphan := &object.Commit{
+ TreeHash: treeHash,
+ Author: object.Signature{Name: "Test", Email: "test@test.com"},
+ Message: "Initialize metadata branch\n",
+ }
+ orphanObj := repo.Storer.NewEncodedObject()
+ if err := orphan.Encode(orphanObj); err != nil {
+ t.Fatalf("failed to encode commit: %v", err)
+ }
+ orphanHash, err := repo.Storer.SetEncodedObject(orphanObj)
+ if err != nil {
+ t.Fatalf("failed to store commit: %v", err)
+ }
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, orphanHash)); err != nil {
+ t.Fatalf("failed to set ref: %v", err)
+ }
+
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("EnsureMetadataBranch() failed: %v", err)
+ }
+
+ // Should have been updated from remote — no longer empty
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+ if ref.Hash() == orphanHash {
+ t.Error("local branch still points to empty orphan — was not updated from remote")
+ }
+ })
+
+ t.Run("creates empty orphan when no remote", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ initTestRepo(t, dir)
+ repo, err := git.PlainOpen(dir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("EnsureMetadataBranch() failed: %v", err)
+ }
+
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("branch not found: %v", err)
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+ if len(tree.Entries) != 0 {
+ t.Errorf("expected empty tree, got %d entries", len(tree.Entries))
+ }
+ })
+}
+
+func TestEnsureMetadataBranch_WritesVercelConfigWhenEnabled(t *testing.T) {
+ vercelconfig.ResetSettingsCache()
+ t.Cleanup(vercelconfig.ResetSettingsCache)
+
+ dir := t.TempDir()
+ initTestRepo(t, dir)
+ if err := os.MkdirAll(filepath.Join(dir, ".trace"), 0o755); err != nil {
+ t.Fatalf("mkdir .trace: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(`{"enabled":true,"vercel":true}`), 0o644); err != nil {
+ t.Fatalf("write settings.json: %v", err)
+ }
+
+ repo, err := git.PlainOpen(dir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+ t.Chdir(dir)
+ if err := vercelconfig.InitSettings(context.Background()); err != nil {
+ t.Fatalf("InitSettings() failed: %v", err)
+ }
+
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("EnsureMetadataBranch() failed: %v", err)
+ }
+
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("branch not found: %v", err)
+ }
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+ file, err := tree.File(vercelconfig.FileName)
+ if err != nil {
+ t.Fatalf("expected %s on metadata branch: %v", vercelconfig.FileName, err)
+ }
+ content, err := file.Contents()
+ if err != nil {
+ t.Fatalf("read %s: %v", vercelconfig.FileName, err)
+ }
+ var config map[string]any
+ if err := json.Unmarshal([]byte(content), &config); err != nil {
+ t.Fatalf("parse %s: %v", vercelconfig.FileName, err)
+ }
+ if !vercelconfig.DeploymentDisabled(config) {
+ t.Fatalf("expected %s to disable %s, got %s", vercelconfig.FileName, vercelconfig.BranchPattern, content)
+ }
+}
+
+// cloneWithConfig clones bareDir into a new temp directory, configures git identity,
+// and returns the clone path and a git runner function.
+func cloneWithConfig(t *testing.T, bareDir string) (string, func(args ...string)) {
+ t.Helper()
+ cloneDir := filepath.Join(t.TempDir(), "clone")
+ cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("clone failed: %v\n%s", err, out)
+ }
+ run := func(args ...string) {
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = cloneDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ }
+ run("config", "user.email", "test@test.com")
+ run("config", "user.name", "Test User")
+ run("config", "commit.gpgsign", "false")
+ return cloneDir, run
+}
+
+func TestEnsureMetadataBranch_DisconnectedBranchesNotReconciledInEnable(t *testing.T) {
+ t.Parallel()
+
+ bareDir := initBareWithMetadataBranch(t)
+ cloneDir, run := cloneWithConfig(t, bareDir)
+
+ // Create a disconnected local branch with different checkpoint data
+ run("checkout", "--orphan", "temp-orphan")
+ run("rm", "-rf", ".")
+ localCheckpointDir := filepath.Join(cloneDir, "ab", "cdef012345")
+ if err := os.MkdirAll(localCheckpointDir, 0o755); err != nil {
+ t.Fatalf("failed to create dir: %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(localCheckpointDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"abcdef012345"}`), 0o644,
+ ); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ run("add", ".")
+ run("commit", "-m", "Checkpoint: abcdef012345")
+ run("branch", "-f", paths.MetadataBranchName, "temp-orphan")
+
+ repo, err := git.PlainOpen(cloneDir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+
+ // Get local ref hash before EnsureMetadataBranch
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ localRefBefore, err := repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("EnsureMetadataBranch() failed: %v", err)
+ }
+
+ // EnsureMetadataBranch should NOT reconcile disconnected branches.
+ // Reconciliation happens at pre-push time or via 'trace doctor'.
+ // The local branch should be unchanged.
+ localRefAfter, err := repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+ if localRefAfter.Hash() != localRefBefore.Hash() {
+ t.Error("EnsureMetadataBranch should not modify disconnected local branch with real data")
+ }
+}
+
+func TestEnsureMetadataBranch_DoesNotFastForwardWhenBehind(t *testing.T) {
+ t.Parallel()
+
+ bareDir := initBareWithMetadataBranch(t)
+ cloneDir, run := cloneWithConfig(t, bareDir)
+
+ // Create local branch from remote (normal state)
+ repo, err := git.PlainOpen(cloneDir)
+ if err != nil {
+ t.Fatalf("failed to open repo: %v", err)
+ }
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("first EnsureMetadataBranch() failed: %v", err)
+ }
+
+ // Remember current local hash
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ localBefore, err := repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+
+ // Add a second checkpoint to the remote (simulates another machine pushing)
+ run("checkout", paths.MetadataBranchName)
+ secondDir := filepath.Join(cloneDir, "cd", "ef01234567")
+ if err := os.MkdirAll(secondDir, 0o755); err != nil {
+ t.Fatalf("failed to create dir: %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(secondDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"cdef01234567"}`), 0o644,
+ ); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ run("add", ".")
+ run("commit", "-m", "Checkpoint: cdef01234567")
+ run("push", "origin", paths.MetadataBranchName)
+
+ // Reset local branch back to the old commit (local is now behind remote)
+ if err := repo.Storer.SetReference(
+ plumbing.NewHashReference(refName, localBefore.Hash()),
+ ); err != nil {
+ t.Fatalf("failed to reset ref: %v", err)
+ }
+
+ // Re-open to clear caches
+ repo, err = git.PlainOpen(cloneDir)
+ if err != nil {
+ t.Fatalf("failed to reopen repo: %v", err)
+ }
+
+ if err := EnsureMetadataBranch(repo); err != nil {
+ t.Fatalf("second EnsureMetadataBranch() failed: %v", err)
+ }
+
+ // EnsureMetadataBranch no longer fast-forwards diverged branches (handled by push path).
+ // Local should be unchanged since it has real data and shares ancestry with remote.
+ localAfter, err := repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("local branch not found: %v", err)
+ }
+ if localAfter.Hash() != localBefore.Hash() {
+ t.Error("EnsureMetadataBranch should not modify local branch with shared ancestry")
+ }
+}
+
+// buildCommittedTree creates a git tree with the sharded committed checkpoint layout
+// used by trace/checkpoints/v1. files is a map of path -> content relative to the tree root.
+// Example: {"a3/b2c4d5e6f7/0/prompt.txt": "Hello"} creates the nested directory structure.
+func buildCommittedTree(t *testing.T, files map[string]string) *object.Tree {
+ t.Helper()
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ for path, content := range files {
+ absPath := filepath.Join(dir, path)
+ if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
+ t.Fatalf("failed to create directory for %s: %v", path, err)
+ }
+ if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write %s: %v", path, err)
+ }
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ if _, err := wt.Add("."); err != nil {
+ t.Fatalf("failed to add files: %v", err)
+ }
+ commitHash, err := wt.Commit("test tree", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ t.Fatalf("failed to get commit: %v", err)
+ }
+ tree, err := commit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+ return tree
+}
+
+func TestReadLatestSessionPromptFromCommittedTree(t *testing.T) {
+ t.Parallel()
+
+ // Checkpoint ID "a3b2c4d5e6f7" -> path "a3/b2c4d5e6f7"
+ cpID := id.MustCheckpointID("a3b2c4d5e6f7")
+
+ t.Run("single session reads from 0/prompt.txt", func(t *testing.T) {
+ t.Parallel()
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Implement login feature",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
+ if got != "Implement login feature" {
+ t.Errorf("got %q, want %q", got, "Implement login feature")
+ }
+ })
+
+ t.Run("multi session reads from latest session", func(t *testing.T) {
+ t.Parallel()
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "First session prompt",
+ "a3/b2c4d5e6f7/1/prompt.txt": "Second session prompt",
+ "a3/b2c4d5e6f7/2/prompt.txt": "Third session prompt",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
+ if got != "Third session prompt" {
+ t.Errorf("got %q, want %q", got, "Third session prompt")
+ }
+ })
+
+ t.Run("falls back to session 0 when computed index missing", func(t *testing.T) {
+ t.Parallel()
+ // Tree only has session 0, but sessionCount says 3
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Fallback prompt",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
+ if got != "Fallback prompt" {
+ t.Errorf("got %q, want %q", got, "Fallback prompt")
+ }
+ })
+
+ t.Run("returns empty for missing prompt.txt", func(t *testing.T) {
+ t.Parallel()
+ // Session directory exists but no prompt.txt
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/metadata.json": `{"session_id":"test"}`,
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
+ if got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+ })
+
+ t.Run("returns empty for missing checkpoint path", func(t *testing.T) {
+ t.Parallel()
+ // Tree has a different checkpoint ID
+ tree := buildCommittedTree(t, map[string]string{
+ "ff/aabbccddee/0/prompt.txt": "Wrong checkpoint",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
+ if got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+ })
+
+ t.Run("returns empty for zero session count", func(t *testing.T) {
+ t.Parallel()
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Some prompt",
+ })
+
+ // sessionCount=0 triggers latestIndex=max(0-1,0)=0, should still read session 0
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 0)
+ if got != "Some prompt" {
+ t.Errorf("got %q, want %q", got, "Some prompt")
+ }
+ })
+
+ t.Run("falls back to earlier session when latest has no prompt", func(t *testing.T) {
+ t.Parallel()
+ // Session 1 (latest) has no prompt.txt, session 0 does.
+ // This happens when a test session gets condensed alongside a real one.
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Real session prompt",
+ "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"test"}`,
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
+ if got != "Real session prompt" {
+ t.Errorf("got %q, want %q", got, "Real session prompt")
+ }
+ })
+
+ t.Run("falls back through multiple empty sessions to find prompt", func(t *testing.T) {
+ t.Parallel()
+ // Sessions 2 and 1 have no prompt, session 0 does.
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Original prompt",
+ "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`,
+ "a3/b2c4d5e6f7/2/metadata.json": `{"session_id":"s2"}`,
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
+ if got != "Original prompt" {
+ t.Errorf("got %q, want %q", got, "Original prompt")
+ }
+ })
+
+ t.Run("returns empty when no session has a prompt", func(t *testing.T) {
+ t.Parallel()
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/metadata.json": `{"session_id":"s0"}`,
+ "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`,
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
+ if got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+ })
+
+ t.Run("falls back when latest has empty prompt.txt", func(t *testing.T) {
+ t.Parallel()
+ // Latest session has a prompt.txt file but it's empty — should fall back.
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "Real prompt",
+ "a3/b2c4d5e6f7/1/prompt.txt": "",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
+ if got != "Real prompt" {
+ t.Errorf("got %q, want %q", got, "Real prompt")
+ }
+ })
+
+ t.Run("extracts first prompt from multi-prompt content", func(t *testing.T) {
+ t.Parallel()
+ tree := buildCommittedTree(t, map[string]string{
+ "a3/b2c4d5e6f7/0/prompt.txt": "First prompt\n\n---\n\nSecond prompt",
+ })
+
+ got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
+ if got != "First prompt" {
+ t.Errorf("got %q, want %q", got, "First prompt")
+ }
+ })
+}
+
+func TestIsEmptyRepository(t *testing.T) {
+ t.Parallel()
+ t.Run("empty repo returns true", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+ if !IsEmptyRepository(repo) {
+ t.Error("IsEmptyRepository() = false, want true for empty repo")
+ }
+ })
+
+ t.Run("repo with commit returns false", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ // Create a commit
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ if _, err := wt.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ if _, err := wt.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com"},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ if IsEmptyRepository(repo) {
+ t.Error("IsEmptyRepository() = true, want false for repo with commit")
+ }
+ })
+}
+
+// openRepoHeadTree opens the repo at dir and returns the HEAD commit tree.
+func openRepoHeadTree(t *testing.T, dir string) *object.Tree {
+ t.Helper()
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+ head, err := repo.Head()
+ require.NoError(t, err)
+ commit, err := repo.CommitObject(head.Hash())
+ require.NoError(t, err)
+ tree, err := commit.Tree()
+ require.NoError(t, err)
+ return tree
+}
+
+func TestReadAgentTypeFromTree_OnlyClaude(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".claude/settings.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeClaudeCode, result)
+}
+
+func TestReadAgentTypeFromTree_OnlyGemini(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".gemini/settings.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeGemini, result)
+}
diff --git a/cli/strategy/common_3.go b/cli/strategy/common_3.go
new file mode 100644
index 0000000..0eaa71c
--- /dev/null
+++ b/cli/strategy/common_3.go
@@ -0,0 +1,212 @@
+package strategy
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// getSessionDescriptionFromTree reads the first line of prompt.txt from a git tree.
+// This is the tree-based equivalent of getSessionDescription (which reads from filesystem).
+//
+// If metadataDir is provided, looks for files at metadataDir/prompt.txt.
+// If metadataDir is empty, first tries the root of the tree (for when the tree is already
+// the session directory), then falls back to
+// searching for .trace/metadata/*/prompt.txt (for full worktree trees).
+func getSessionDescriptionFromTree(tree *object.Tree, metadataDir string) string {
+ // Helper to read first line from a file in tree
+ readFirstLine := func(path string) string {
+ file, err := tree.File(path)
+ if err != nil {
+ return ""
+ }
+ content, err := file.Contents()
+ if err != nil {
+ return ""
+ }
+ lines := strings.SplitN(content, "\n", 2)
+ if len(lines) > 0 && lines[0] != "" {
+ return strings.TrimSpace(lines[0])
+ }
+ return ""
+ }
+
+ // If metadataDir is provided, look there directly
+ if metadataDir != "" {
+ if desc := readFirstLine(metadataDir + "/" + paths.PromptFileName); desc != "" {
+ return desc
+ }
+ return NoDescription
+ }
+
+ // No metadataDir provided - first try looking at the root of the tree
+ // (used when the tree is already the session directory)
+ if desc := readFirstLine(paths.PromptFileName); desc != "" {
+ return desc
+ }
+
+ // Fall back to searching for .trace/metadata/*/prompt.txt
+ // (used when the tree is the full worktree)
+ var desc string
+ //nolint:errcheck // We ignore errors here as we're just searching for a description
+ _ = tree.Files().ForEach(func(f *object.File) error {
+ if desc != "" {
+ return nil // Already found description
+ }
+ name := f.Name
+ if strings.Contains(name, ".trace/metadata/") && strings.HasSuffix(name, "/"+paths.PromptFileName) {
+ content, err := f.Contents()
+ if err != nil {
+ return nil //nolint:nilerr // Skip files we can't read, continue searching
+ }
+ lines := strings.SplitN(content, "\n", 2)
+ if len(lines) > 0 && lines[0] != "" {
+ desc = strings.TrimSpace(lines[0])
+ }
+ }
+ return nil
+ })
+
+ if desc != "" {
+ return desc
+ }
+ return NoDescription
+}
+
+// GetGitAuthorFromRepo retrieves the git user.name and user.email,
+// checking both the repository-local config and the global ~/.gitconfig.
+// Delegates to checkpoint.GetGitAuthorFromRepo — this wrapper exists so
+// callers within the strategy package don't need a qualified import.
+func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
+ return checkpoint.GetGitAuthorFromRepo(repo)
+}
+
+// GetCurrentBranchName returns the short name of the current branch if HEAD points to a branch.
+// Returns an empty string if in detached HEAD state or if there's an error reading HEAD.
+// This is used to capture branch metadata for checkpoints.
+func GetCurrentBranchName(repo *git.Repository) string {
+ head, err := repo.Head()
+ if err != nil || !head.Name().IsBranch() {
+ return ""
+ }
+ return head.Name().Short()
+}
+
+// getMainBranchHash returns the hash of the main branch (main or master).
+// Returns ZeroHash if no main branch is found.
+func GetMainBranchHash(repo *git.Repository) plumbing.Hash {
+ // Try common main branch names
+ for _, branchName := range []string{branchMain, branchMaster} {
+ // Try local branch first
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ if err == nil {
+ return ref.Hash()
+ }
+ // Try remote tracking branch
+ ref, err = repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true)
+ if err == nil {
+ return ref.Hash()
+ }
+ }
+ return plumbing.ZeroHash
+}
+
+// GetDefaultBranchName returns the name of the default branch.
+// First checks origin/HEAD, then falls back to checking if main/master exists.
+// Returns empty string if unable to determine.
+// NOTE: Duplicated from cli/git_operations.go - see ENT-129 for consolidation.
+func GetDefaultBranchName(repo *git.Repository) string {
+ // Try to get the symbolic reference for origin/HEAD
+ // Use resolved=false to get the symbolic ref itself, then extract its target
+ ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), false)
+ if err == nil && ref != nil && ref.Type() == plumbing.SymbolicReference {
+ target := ref.Target().String()
+ if branchName, found := strings.CutPrefix(target, "refs/remotes/origin/"); found {
+ return branchName
+ }
+ }
+
+ // Fallback: check if origin/main or origin/master exists
+ if _, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchMain), true); err == nil {
+ return branchMain
+ }
+ if _, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchMaster), true); err == nil {
+ return branchMaster
+ }
+
+ // Final fallback: check local branches
+ if _, err := repo.Reference(plumbing.NewBranchReferenceName(branchMain), true); err == nil {
+ return branchMain
+ }
+ if _, err := repo.Reference(plumbing.NewBranchReferenceName(branchMaster), true); err == nil {
+ return branchMaster
+ }
+
+ return ""
+}
+
+// IsOnDefaultBranch checks if the repository HEAD is on the default branch.
+// Returns (isOnDefault, currentBranchName).
+// NOTE: Duplicated from cli/git_operations.go - see ENT-129 for consolidation.
+func IsOnDefaultBranch(repo *git.Repository) (bool, string) {
+ currentBranch := GetCurrentBranchName(repo)
+ if currentBranch == "" {
+ return false, ""
+ }
+
+ defaultBranch := GetDefaultBranchName(repo)
+ if defaultBranch == "" {
+ // Can't determine default, check common names
+ if currentBranch == branchMain || currentBranch == branchMaster {
+ return true, currentBranch
+ }
+ return false, currentBranch
+ }
+
+ return currentBranch == defaultBranch, currentBranch
+}
+
+// prepareTranscriptForState ensures the transcript is up-to-date for the given session.
+// Only prepares for ACTIVE sessions — IDLE/ENDED sessions are already flushed.
+// Resolves the agent from state.AgentType internally. Multiple calls are safe but
+// not free — callers should avoid redundant calls for performance.
+func prepareTranscriptForState(ctx context.Context, state *SessionState) {
+ if !state.Phase.IsActive() || state.TranscriptPath == "" || state.AgentType == "" {
+ return
+ }
+ ag, err := agent.GetByAgentType(state.AgentType)
+ if err != nil {
+ logging.Debug(
+ ctx, "prepareTranscriptForState: unknown agent type",
+ slog.String("session_id", state.SessionID),
+ slog.String("agent_type", string(state.AgentType)),
+ slog.Any("error", err),
+ )
+ return
+ }
+ prepareTranscriptIfNeeded(ctx, ag, state.TranscriptPath)
+}
+
+// prepareTranscriptIfNeeded calls PrepareTranscript for agents that implement
+// the TranscriptPreparer interface. This ensures transcript files exist before
+// they are read (e.g., OpenCode creates its transcript lazily via `opencode export`).
+// Errors are silently ignored — this is best-effort for hook paths.
+func prepareTranscriptIfNeeded(ctx context.Context, ag agent.Agent, transcriptPath string) {
+ if ag == nil || transcriptPath == "" {
+ return
+ }
+ if preparer, ok := agent.AsTranscriptPreparer(ag); ok {
+ // Best-effort: callers handle missing files gracefully.
+ // Transcript may not be available yet (e.g., agent not installed).
+ _ = preparer.PrepareTranscript(ctx, transcriptPath) //nolint:errcheck // Best-effort in hook path
+ }
+}
diff --git a/cli/strategy/common_3_test.go b/cli/strategy/common_3_test.go
new file mode 100644
index 0000000..eecfe93
--- /dev/null
+++ b/cli/strategy/common_3_test.go
@@ -0,0 +1,115 @@
+package strategy
+
+import (
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ _ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestReadAgentTypeFromTree_OnlyCodex(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".codex/config.json", `{}`)
+ testutil.GitAdd(t, dir, ".codex/config.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeCodex, result)
+}
+
+func TestReadAgentTypeFromTree_OnlyCursor(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".cursor/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".cursor/settings.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeCursor, result)
+}
+
+func TestReadAgentTypeFromTree_OnlyFactory(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".factory/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".factory/settings.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeFactoryAIDroid, result)
+}
+
+func TestReadAgentTypeFromTree_ClaudeAndCodex_ReturnsUnknown(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".claude/settings.json")
+ testutil.WriteFile(t, dir, ".codex/config.json", `{}`)
+ testutil.GitAdd(t, dir, ".codex/config.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeUnknown, result)
+}
+
+func TestReadAgentTypeFromTree_ClaudeAndGemini_ReturnsUnknown(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".claude/settings.json")
+ testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".gemini/settings.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeUnknown, result)
+}
+
+func TestReadAgentTypeFromTree_NoAgentDirs_ReturnsUnknown(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "f.txt", "init")
+ testutil.GitAdd(t, dir, "f.txt")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "nonexistent-path")
+ assert.Equal(t, agent.AgentTypeUnknown, result)
+}
+
+func TestReadAgentTypeFromTree_MetadataJSON_OverridesDir(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
+ testutil.GitAdd(t, dir, ".claude/settings.json")
+ testutil.WriteFile(t, dir, "cp/metadata.json", `{"agent":"Cursor"}`)
+ testutil.GitAdd(t, dir, "cp/metadata.json")
+ testutil.GitCommit(t, dir, "init")
+
+ tree := openRepoHeadTree(t, dir)
+ result := ReadAgentTypeFromTree(tree, "cp")
+ assert.Equal(t, agent.AgentTypeCursor, result)
+}
diff --git a/cli/strategy/common_test.go b/cli/strategy/common_test.go
index 68a2339..7c1da0f 100644
--- a/cli/strategy/common_test.go
+++ b/cli/strategy/common_test.go
@@ -2,25 +2,18 @@ package strategy
import (
"context"
- "encoding/json"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
- "github.com/GrayCodeAI/trace/cli/agent"
_ "github.com/GrayCodeAI/trace/cli/agent/claudecode"
- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/testutil"
- "github.com/GrayCodeAI/trace/cli/vercelconfig"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestWorktreeRoot_Cache(t *testing.T) {
@@ -778,895 +771,3 @@ func resetProtectedDirsForTest() {
protectedDirsOnce = sync.Once{}
protectedDirsCache = nil
}
-
-func TestGetGitAuthorFromRepo(t *testing.T) {
- // Cannot use t.Parallel() because subtests use t.Setenv to isolate global git config.
-
- tests := []struct {
- name string
- localName string
- localEmail string
- globalName string
- globalEmail string
- wantName string
- wantEmail string
- }{
- {
- name: "both set locally",
- localName: "Local User",
- localEmail: "local@example.com",
- wantName: "Local User",
- wantEmail: "local@example.com",
- },
- {
- name: "only name set locally falls back to global for email",
- localName: "Local User",
- globalEmail: "global@example.com",
- wantName: "Local User",
- wantEmail: "global@example.com",
- },
- {
- name: "only email set locally falls back to global for name",
- localEmail: "local@example.com",
- globalName: "Global User",
- wantName: "Global User",
- wantEmail: "local@example.com",
- },
- {
- name: "nothing set locally falls back to global for both",
- globalName: "Global User",
- globalEmail: "global@example.com",
- wantName: "Global User",
- wantEmail: "global@example.com",
- },
- {
- name: "nothing set anywhere returns defaults",
- wantName: "Unknown",
- wantEmail: "unknown@local",
- },
- {
- name: "local takes precedence over global",
- localName: "Local User",
- localEmail: "local@example.com",
- globalName: "Global User",
- globalEmail: "global@example.com",
- wantName: "Local User",
- wantEmail: "local@example.com",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- useAutoConfigLoader(t)
-
- // Isolate global git config by pointing HOME to a temp dir
- home := t.TempDir()
- t.Setenv("HOME", home)
- t.Setenv("XDG_CONFIG_HOME", "")
-
- // Write global .gitconfig if needed
- if tt.globalName != "" || tt.globalEmail != "" {
- globalCfg := "[user]\n"
- if tt.globalName != "" {
- globalCfg += "\tname = " + tt.globalName + "\n"
- }
- if tt.globalEmail != "" {
- globalCfg += "\temail = " + tt.globalEmail + "\n"
- }
- if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalCfg), 0o644); err != nil {
- t.Fatalf("failed to write global gitconfig: %v", err)
- }
- }
-
- // Create a repo for config resolution
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- // Set local config if needed
- if tt.localName != "" || tt.localEmail != "" {
- cfg, err := repo.Config()
- if err != nil {
- t.Fatalf("failed to get repo config: %v", err)
- }
- cfg.User.Name = tt.localName
- cfg.User.Email = tt.localEmail
- if err := repo.SetConfig(cfg); err != nil {
- t.Fatalf("failed to set repo config: %v", err)
- }
- }
-
- gotName, gotEmail := GetGitAuthorFromRepo(repo)
- if gotName != tt.wantName {
- t.Errorf("name = %q, want %q", gotName, tt.wantName)
- }
- if gotEmail != tt.wantEmail {
- t.Errorf("email = %q, want %q", gotEmail, tt.wantEmail)
- }
- })
- }
-}
-
-func TestIsProtectedPath(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- path string
- protected bool
- }{
- {".git", true},
- {".git/objects", true},
- {".trace", true},
- {".trace/metadata/session.json", true},
- {".claude", true},
- {".claude/settings.json", true},
- {".gemini", true},
- {".gemini/settings.json", true},
- {"src/main.go", false},
- {"README.md", false},
- {".gitignore", false},
- {".github/workflows/ci.yml", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.path, func(t *testing.T) {
- t.Parallel()
- if got := isProtectedPath(tt.path); got != tt.protected {
- t.Errorf("isProtectedPath(%q) = %v, want %v", tt.path, got, tt.protected)
- }
- })
- }
-}
-
-// initBareWithMetadataBranch creates a bare repo with a main branch and an
-// trace/checkpoints/v1 branch containing checkpoint data via git CLI.
-func initBareWithMetadataBranch(t *testing.T) string {
- t.Helper()
- bareDir := t.TempDir()
-
- // Init bare, create main branch with a commit
- workDir := t.TempDir()
- run := func(dir string, args ...string) {
- cmd := exec.CommandContext(context.Background(), "git", args...)
- cmd.Dir = dir
- cmd.Env = testutil.GitIsolatedEnv()
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git %v failed: %v\n%s", args, err, out)
- }
- }
- run(bareDir, "init", "--bare", "-b", "main")
- run(workDir, "clone", bareDir, ".")
- run(workDir, "config", "user.email", "test@test.com")
- run(workDir, "config", "user.name", "Test User")
- run(workDir, "config", "commit.gpgsign", "false")
- if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- run(workDir, "add", ".")
- run(workDir, "commit", "-m", "init")
- run(workDir, "push", "origin", "main")
-
- // Create orphan trace/checkpoints/v1 with data
- run(workDir, "checkout", "--orphan", paths.MetadataBranchName)
- run(workDir, "rm", "-rf", ".")
- if err := os.WriteFile(filepath.Join(workDir, "metadata.json"), []byte(`{"checkpoint_id":"test123"}`), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- run(workDir, "add", ".")
- run(workDir, "commit", "-m", "Checkpoint: test123")
- run(workDir, "push", "origin", paths.MetadataBranchName)
-
- return bareDir
-}
-
-func TestEnsureMetadataBranch(t *testing.T) {
- t.Parallel()
-
- t.Run("creates from remote on fresh clone", func(t *testing.T) {
- bareDir := initBareWithMetadataBranch(t)
- cloneDir := filepath.Join(t.TempDir(), "clone")
- cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("clone failed: %v\n%s", err, out)
- }
-
- repo, err := git.PlainOpen(cloneDir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
-
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("EnsureMetadataBranch() failed: %v", err)
- }
-
- // Local branch should exist with data (not empty)
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
- if len(tree.Entries) == 0 {
- t.Error("local branch has empty tree — remote data was not preserved")
- }
- })
-
- t.Run("updates empty orphan from remote", func(t *testing.T) {
- t.Parallel()
- bareDir := initBareWithMetadataBranch(t)
- cloneDir := filepath.Join(t.TempDir(), "clone")
- cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("clone failed: %v\n%s", err, out)
- }
-
- repo, err := git.PlainOpen(cloneDir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
-
- // Create an empty orphan locally (simulates old enable behavior)
- emptyTree := &object.Tree{Entries: []object.TreeEntry{}}
- treeObj := repo.Storer.NewEncodedObject()
- if err := emptyTree.Encode(treeObj); err != nil {
- t.Fatalf("failed to encode tree: %v", err)
- }
- treeHash, err := repo.Storer.SetEncodedObject(treeObj)
- if err != nil {
- t.Fatalf("failed to store tree: %v", err)
- }
- orphan := &object.Commit{
- TreeHash: treeHash,
- Author: object.Signature{Name: "Test", Email: "test@test.com"},
- Message: "Initialize metadata branch\n",
- }
- orphanObj := repo.Storer.NewEncodedObject()
- if err := orphan.Encode(orphanObj); err != nil {
- t.Fatalf("failed to encode commit: %v", err)
- }
- orphanHash, err := repo.Storer.SetEncodedObject(orphanObj)
- if err != nil {
- t.Fatalf("failed to store commit: %v", err)
- }
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, orphanHash)); err != nil {
- t.Fatalf("failed to set ref: %v", err)
- }
-
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("EnsureMetadataBranch() failed: %v", err)
- }
-
- // Should have been updated from remote — no longer empty
- ref, err := repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
- if ref.Hash() == orphanHash {
- t.Error("local branch still points to empty orphan — was not updated from remote")
- }
- })
-
- t.Run("creates empty orphan when no remote", func(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- initTestRepo(t, dir)
- repo, err := git.PlainOpen(dir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("EnsureMetadataBranch() failed: %v", err)
- }
-
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("branch not found: %v", err)
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
- if len(tree.Entries) != 0 {
- t.Errorf("expected empty tree, got %d entries", len(tree.Entries))
- }
- })
-}
-
-func TestEnsureMetadataBranch_WritesVercelConfigWhenEnabled(t *testing.T) {
- vercelconfig.ResetSettingsCache()
- t.Cleanup(vercelconfig.ResetSettingsCache)
-
- dir := t.TempDir()
- initTestRepo(t, dir)
- if err := os.MkdirAll(filepath.Join(dir, ".trace"), 0o755); err != nil {
- t.Fatalf("mkdir .trace: %v", err)
- }
- if err := os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(`{"enabled":true,"vercel":true}`), 0o644); err != nil {
- t.Fatalf("write settings.json: %v", err)
- }
-
- repo, err := git.PlainOpen(dir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
- t.Chdir(dir)
- if err := vercelconfig.InitSettings(context.Background()); err != nil {
- t.Fatalf("InitSettings() failed: %v", err)
- }
-
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("EnsureMetadataBranch() failed: %v", err)
- }
-
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("branch not found: %v", err)
- }
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
- file, err := tree.File(vercelconfig.FileName)
- if err != nil {
- t.Fatalf("expected %s on metadata branch: %v", vercelconfig.FileName, err)
- }
- content, err := file.Contents()
- if err != nil {
- t.Fatalf("read %s: %v", vercelconfig.FileName, err)
- }
- var config map[string]any
- if err := json.Unmarshal([]byte(content), &config); err != nil {
- t.Fatalf("parse %s: %v", vercelconfig.FileName, err)
- }
- if !vercelconfig.DeploymentDisabled(config) {
- t.Fatalf("expected %s to disable %s, got %s", vercelconfig.FileName, vercelconfig.BranchPattern, content)
- }
-}
-
-// cloneWithConfig clones bareDir into a new temp directory, configures git identity,
-// and returns the clone path and a git runner function.
-func cloneWithConfig(t *testing.T, bareDir string) (string, func(args ...string)) {
- t.Helper()
- cloneDir := filepath.Join(t.TempDir(), "clone")
- cmd := exec.CommandContext(context.Background(), "git", "clone", bareDir, cloneDir)
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("clone failed: %v\n%s", err, out)
- }
- run := func(args ...string) {
- cmd := exec.CommandContext(context.Background(), "git", args...)
- cmd.Dir = cloneDir
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git %v failed: %v\n%s", args, err, out)
- }
- }
- run("config", "user.email", "test@test.com")
- run("config", "user.name", "Test User")
- run("config", "commit.gpgsign", "false")
- return cloneDir, run
-}
-
-func TestEnsureMetadataBranch_DisconnectedBranchesNotReconciledInEnable(t *testing.T) {
- t.Parallel()
-
- bareDir := initBareWithMetadataBranch(t)
- cloneDir, run := cloneWithConfig(t, bareDir)
-
- // Create a disconnected local branch with different checkpoint data
- run("checkout", "--orphan", "temp-orphan")
- run("rm", "-rf", ".")
- localCheckpointDir := filepath.Join(cloneDir, "ab", "cdef012345")
- if err := os.MkdirAll(localCheckpointDir, 0o755); err != nil {
- t.Fatalf("failed to create dir: %v", err)
- }
- if err := os.WriteFile(
- filepath.Join(localCheckpointDir, "metadata.json"),
- []byte(`{"checkpoint_id":"abcdef012345"}`), 0o644,
- ); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- run("add", ".")
- run("commit", "-m", "Checkpoint: abcdef012345")
- run("branch", "-f", paths.MetadataBranchName, "temp-orphan")
-
- repo, err := git.PlainOpen(cloneDir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
-
- // Get local ref hash before EnsureMetadataBranch
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- localRefBefore, err := repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
-
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("EnsureMetadataBranch() failed: %v", err)
- }
-
- // EnsureMetadataBranch should NOT reconcile disconnected branches.
- // Reconciliation happens at pre-push time or via 'trace doctor'.
- // The local branch should be unchanged.
- localRefAfter, err := repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
- if localRefAfter.Hash() != localRefBefore.Hash() {
- t.Error("EnsureMetadataBranch should not modify disconnected local branch with real data")
- }
-}
-
-func TestEnsureMetadataBranch_DoesNotFastForwardWhenBehind(t *testing.T) {
- t.Parallel()
-
- bareDir := initBareWithMetadataBranch(t)
- cloneDir, run := cloneWithConfig(t, bareDir)
-
- // Create local branch from remote (normal state)
- repo, err := git.PlainOpen(cloneDir)
- if err != nil {
- t.Fatalf("failed to open repo: %v", err)
- }
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("first EnsureMetadataBranch() failed: %v", err)
- }
-
- // Remember current local hash
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- localBefore, err := repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
-
- // Add a second checkpoint to the remote (simulates another machine pushing)
- run("checkout", paths.MetadataBranchName)
- secondDir := filepath.Join(cloneDir, "cd", "ef01234567")
- if err := os.MkdirAll(secondDir, 0o755); err != nil {
- t.Fatalf("failed to create dir: %v", err)
- }
- if err := os.WriteFile(
- filepath.Join(secondDir, "metadata.json"),
- []byte(`{"checkpoint_id":"cdef01234567"}`), 0o644,
- ); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- run("add", ".")
- run("commit", "-m", "Checkpoint: cdef01234567")
- run("push", "origin", paths.MetadataBranchName)
-
- // Reset local branch back to the old commit (local is now behind remote)
- if err := repo.Storer.SetReference(
- plumbing.NewHashReference(refName, localBefore.Hash()),
- ); err != nil {
- t.Fatalf("failed to reset ref: %v", err)
- }
-
- // Re-open to clear caches
- repo, err = git.PlainOpen(cloneDir)
- if err != nil {
- t.Fatalf("failed to reopen repo: %v", err)
- }
-
- if err := EnsureMetadataBranch(repo); err != nil {
- t.Fatalf("second EnsureMetadataBranch() failed: %v", err)
- }
-
- // EnsureMetadataBranch no longer fast-forwards diverged branches (handled by push path).
- // Local should be unchanged since it has real data and shares ancestry with remote.
- localAfter, err := repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("local branch not found: %v", err)
- }
- if localAfter.Hash() != localBefore.Hash() {
- t.Error("EnsureMetadataBranch should not modify local branch with shared ancestry")
- }
-}
-
-// buildCommittedTree creates a git tree with the sharded committed checkpoint layout
-// used by trace/checkpoints/v1. files is a map of path -> content relative to the tree root.
-// Example: {"a3/b2c4d5e6f7/0/prompt.txt": "Hello"} creates the nested directory structure.
-func buildCommittedTree(t *testing.T, files map[string]string) *object.Tree {
- t.Helper()
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- for path, content := range files {
- absPath := filepath.Join(dir, path)
- if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
- t.Fatalf("failed to create directory for %s: %v", path, err)
- }
- if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
- t.Fatalf("failed to write %s: %v", path, err)
- }
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- if _, err := wt.Add("."); err != nil {
- t.Fatalf("failed to add files: %v", err)
- }
- commitHash, err := wt.Commit("test tree", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- t.Fatalf("failed to get commit: %v", err)
- }
- tree, err := commit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
- return tree
-}
-
-func TestReadLatestSessionPromptFromCommittedTree(t *testing.T) {
- t.Parallel()
-
- // Checkpoint ID "a3b2c4d5e6f7" -> path "a3/b2c4d5e6f7"
- cpID := id.MustCheckpointID("a3b2c4d5e6f7")
-
- t.Run("single session reads from 0/prompt.txt", func(t *testing.T) {
- t.Parallel()
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Implement login feature",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
- if got != "Implement login feature" {
- t.Errorf("got %q, want %q", got, "Implement login feature")
- }
- })
-
- t.Run("multi session reads from latest session", func(t *testing.T) {
- t.Parallel()
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "First session prompt",
- "a3/b2c4d5e6f7/1/prompt.txt": "Second session prompt",
- "a3/b2c4d5e6f7/2/prompt.txt": "Third session prompt",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
- if got != "Third session prompt" {
- t.Errorf("got %q, want %q", got, "Third session prompt")
- }
- })
-
- t.Run("falls back to session 0 when computed index missing", func(t *testing.T) {
- t.Parallel()
- // Tree only has session 0, but sessionCount says 3
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Fallback prompt",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
- if got != "Fallback prompt" {
- t.Errorf("got %q, want %q", got, "Fallback prompt")
- }
- })
-
- t.Run("returns empty for missing prompt.txt", func(t *testing.T) {
- t.Parallel()
- // Session directory exists but no prompt.txt
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/metadata.json": `{"session_id":"test"}`,
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
- if got != "" {
- t.Errorf("got %q, want empty string", got)
- }
- })
-
- t.Run("returns empty for missing checkpoint path", func(t *testing.T) {
- t.Parallel()
- // Tree has a different checkpoint ID
- tree := buildCommittedTree(t, map[string]string{
- "ff/aabbccddee/0/prompt.txt": "Wrong checkpoint",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
- if got != "" {
- t.Errorf("got %q, want empty string", got)
- }
- })
-
- t.Run("returns empty for zero session count", func(t *testing.T) {
- t.Parallel()
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Some prompt",
- })
-
- // sessionCount=0 triggers latestIndex=max(0-1,0)=0, should still read session 0
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 0)
- if got != "Some prompt" {
- t.Errorf("got %q, want %q", got, "Some prompt")
- }
- })
-
- t.Run("falls back to earlier session when latest has no prompt", func(t *testing.T) {
- t.Parallel()
- // Session 1 (latest) has no prompt.txt, session 0 does.
- // This happens when a test session gets condensed alongside a real one.
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Real session prompt",
- "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"test"}`,
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
- if got != "Real session prompt" {
- t.Errorf("got %q, want %q", got, "Real session prompt")
- }
- })
-
- t.Run("falls back through multiple empty sessions to find prompt", func(t *testing.T) {
- t.Parallel()
- // Sessions 2 and 1 have no prompt, session 0 does.
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Original prompt",
- "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`,
- "a3/b2c4d5e6f7/2/metadata.json": `{"session_id":"s2"}`,
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 3)
- if got != "Original prompt" {
- t.Errorf("got %q, want %q", got, "Original prompt")
- }
- })
-
- t.Run("returns empty when no session has a prompt", func(t *testing.T) {
- t.Parallel()
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/metadata.json": `{"session_id":"s0"}`,
- "a3/b2c4d5e6f7/1/metadata.json": `{"session_id":"s1"}`,
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
- if got != "" {
- t.Errorf("got %q, want empty string", got)
- }
- })
-
- t.Run("falls back when latest has empty prompt.txt", func(t *testing.T) {
- t.Parallel()
- // Latest session has a prompt.txt file but it's empty — should fall back.
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "Real prompt",
- "a3/b2c4d5e6f7/1/prompt.txt": "",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 2)
- if got != "Real prompt" {
- t.Errorf("got %q, want %q", got, "Real prompt")
- }
- })
-
- t.Run("extracts first prompt from multi-prompt content", func(t *testing.T) {
- t.Parallel()
- tree := buildCommittedTree(t, map[string]string{
- "a3/b2c4d5e6f7/0/prompt.txt": "First prompt\n\n---\n\nSecond prompt",
- })
-
- got := ReadLatestSessionPromptFromCommittedTree(tree, cpID, 1)
- if got != "First prompt" {
- t.Errorf("got %q, want %q", got, "First prompt")
- }
- })
-}
-
-func TestIsEmptyRepository(t *testing.T) {
- t.Parallel()
- t.Run("empty repo returns true", func(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
- if !IsEmptyRepository(repo) {
- t.Error("IsEmptyRepository() = false, want true for empty repo")
- }
- })
-
- t.Run("repo with commit returns false", func(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- // Create a commit
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- if _, err := wt.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- if _, err := wt.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com"},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- if IsEmptyRepository(repo) {
- t.Error("IsEmptyRepository() = true, want false for repo with commit")
- }
- })
-}
-
-// openRepoHeadTree opens the repo at dir and returns the HEAD commit tree.
-func openRepoHeadTree(t *testing.T, dir string) *object.Tree {
- t.Helper()
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
- head, err := repo.Head()
- require.NoError(t, err)
- commit, err := repo.CommitObject(head.Hash())
- require.NoError(t, err)
- tree, err := commit.Tree()
- require.NoError(t, err)
- return tree
-}
-
-func TestReadAgentTypeFromTree_OnlyClaude(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".claude/settings.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeClaudeCode, result)
-}
-
-func TestReadAgentTypeFromTree_OnlyGemini(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".gemini/settings.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeGemini, result)
-}
-
-func TestReadAgentTypeFromTree_OnlyCodex(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".codex/config.json", `{}`)
- testutil.GitAdd(t, dir, ".codex/config.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeCodex, result)
-}
-
-func TestReadAgentTypeFromTree_OnlyCursor(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".cursor/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".cursor/settings.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeCursor, result)
-}
-
-func TestReadAgentTypeFromTree_OnlyFactory(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".factory/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".factory/settings.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeFactoryAIDroid, result)
-}
-
-func TestReadAgentTypeFromTree_ClaudeAndCodex_ReturnsUnknown(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".claude/settings.json")
- testutil.WriteFile(t, dir, ".codex/config.json", `{}`)
- testutil.GitAdd(t, dir, ".codex/config.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeUnknown, result)
-}
-
-func TestReadAgentTypeFromTree_ClaudeAndGemini_ReturnsUnknown(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".claude/settings.json")
- testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".gemini/settings.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeUnknown, result)
-}
-
-func TestReadAgentTypeFromTree_NoAgentDirs_ReturnsUnknown(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "f.txt", "init")
- testutil.GitAdd(t, dir, "f.txt")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "nonexistent-path")
- assert.Equal(t, agent.AgentTypeUnknown, result)
-}
-
-func TestReadAgentTypeFromTree_MetadataJSON_OverridesDir(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, ".claude/settings.json", `{}`)
- testutil.GitAdd(t, dir, ".claude/settings.json")
- testutil.WriteFile(t, dir, "cp/metadata.json", `{"agent":"Cursor"}`)
- testutil.GitAdd(t, dir, "cp/metadata.json")
- testutil.GitCommit(t, dir, "init")
-
- tree := openRepoHeadTree(t, dir)
- result := ReadAgentTypeFromTree(tree, "cp")
- assert.Equal(t, agent.AgentTypeCursor, result)
-}
diff --git a/cli/strategy/content_overlap_2_test.go b/cli/strategy/content_overlap_2_test.go
new file mode 100644
index 0000000..49a9f07
--- /dev/null
+++ b/cli/strategy/content_overlap_2_test.go
@@ -0,0 +1,548 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestStagedFilesOverlapWithContent_ModifiedFile tests that a modified file
+// (exists in HEAD) always counts as overlap.
+func TestStagedFilesOverlapWithContent_ModifiedFile(t *testing.T) {
+ t.Parallel()
+ dir := setupGitRepo(t)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ // Initial file is created by setupGitRepo
+ // Modify it and stage
+ testFile := filepath.Join(dir, "test.txt")
+ require.NoError(t, os.WriteFile(testFile, []byte("modified content"), 0o644))
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+
+ // Create shadow branch (content doesn't matter for modified files)
+ createShadowBranchWithContent(t, repo, "abc1234", "e3b0c4", map[string][]byte{
+ "test.txt": []byte("shadow content"),
+ })
+
+ // Get shadow tree
+ shadowBranch := checkpoint.ShadowBranchNameForCommit("abc1234", "e3b0c4")
+ shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ require.NoError(t, err)
+ shadowTree, err := shadowCommit.Tree()
+ require.NoError(t, err)
+
+ // Modified file should count as overlap regardless of content
+ result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"test.txt"}, []string{"test.txt"})
+ assert.True(t, result, "Modified file should always count as overlap")
+}
+
+// TestStagedFilesOverlapWithContent_NewFile_ContentMatch tests that a new file
+// with matching content counts as overlap.
+func TestStagedFilesOverlapWithContent_NewFile_ContentMatch(t *testing.T) {
+ t.Parallel()
+ dir := setupGitRepo(t)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ // Create a NEW file (doesn't exist in HEAD)
+ content := []byte("new file content")
+ newFile := filepath.Join(dir, "newfile.txt")
+ require.NoError(t, os.WriteFile(newFile, content, 0o644))
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("newfile.txt")
+ require.NoError(t, err)
+
+ // Create shadow branch with SAME content
+ createShadowBranchWithContent(t, repo, "def5678", "e3b0c4", map[string][]byte{
+ "newfile.txt": content,
+ })
+
+ // Get shadow tree
+ shadowBranch := checkpoint.ShadowBranchNameForCommit("def5678", "e3b0c4")
+ shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ require.NoError(t, err)
+ shadowTree, err := shadowCommit.Tree()
+ require.NoError(t, err)
+
+ // New file with matching content should count as overlap
+ result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"newfile.txt"}, []string{"newfile.txt"})
+ assert.True(t, result, "New file with matching content should count as overlap")
+}
+
+// TestStagedFilesOverlapWithContent_NewFile_ContentMismatch tests that a new file
+// with different content does NOT count as overlap (reverted & replaced scenario).
+func TestStagedFilesOverlapWithContent_NewFile_ContentMismatch(t *testing.T) {
+ t.Parallel()
+ dir := setupGitRepo(t)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ // Create a NEW file with different content than shadow branch
+ newFile := filepath.Join(dir, "newfile.txt")
+ require.NoError(t, os.WriteFile(newFile, []byte("user replaced content"), 0o644))
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("newfile.txt")
+ require.NoError(t, err)
+
+ // Create shadow branch with DIFFERENT content (agent's original)
+ createShadowBranchWithContent(t, repo, "ghi9012", "e3b0c4", map[string][]byte{
+ "newfile.txt": []byte("agent original content"),
+ })
+
+ // Get shadow tree
+ shadowBranch := checkpoint.ShadowBranchNameForCommit("ghi9012", "e3b0c4")
+ shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ require.NoError(t, err)
+ shadowTree, err := shadowCommit.Tree()
+ require.NoError(t, err)
+
+ // New file with different content should NOT count as overlap
+ result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"newfile.txt"}, []string{"newfile.txt"})
+ assert.False(t, result, "New file with mismatched content should not count as overlap")
+}
+
+// TestStagedFilesOverlapWithContent_NoOverlap tests that non-overlapping files
+// return false.
+func TestStagedFilesOverlapWithContent_NoOverlap(t *testing.T) {
+ t.Parallel()
+ dir := setupGitRepo(t)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ // Stage a file NOT in filesTouched
+ otherFile := filepath.Join(dir, "other.txt")
+ require.NoError(t, os.WriteFile(otherFile, []byte("other content"), 0o644))
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("other.txt")
+ require.NoError(t, err)
+
+ // Create shadow branch
+ createShadowBranchWithContent(t, repo, "jkl3456", "e3b0c4", map[string][]byte{
+ "session.txt": []byte("session content"),
+ })
+
+ // Get shadow tree
+ shadowBranch := checkpoint.ShadowBranchNameForCommit("jkl3456", "e3b0c4")
+ shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ require.NoError(t, err)
+ shadowTree, err := shadowCommit.Tree()
+ require.NoError(t, err)
+
+ // Staged file "other.txt" is not in filesTouched "session.txt"
+ result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"other.txt"}, []string{"session.txt"})
+ assert.False(t, result, "Non-overlapping files should return false")
+}
+
+// TestStagedFilesOverlapWithContent_DeletedFile tests that a deleted file
+// (exists in HEAD but staged for deletion) DOES count as overlap.
+// The agent's action of deleting the file is being committed, so the session
+// context should be linked to this commit.
+func TestStagedFilesOverlapWithContent_DeletedFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ require.NoError(t, err)
+
+ // Create and commit a file that will be deleted
+ filePath := filepath.Join(dir, "to_delete.txt")
+ err = os.WriteFile(filePath, []byte("original content"), 0o644)
+ require.NoError(t, err)
+ _, err = worktree.Add("to_delete.txt")
+ require.NoError(t, err)
+ _, err = worktree.Commit("Add to_delete.txt", &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@test.com",
+ When: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // Create shadow branch (simulating agent work on the file)
+ createShadowBranchWithContent(t, repo, "mno7890", "e3b0c4", map[string][]byte{
+ "to_delete.txt": []byte("agent modified content"),
+ })
+
+ // Stage the file for deletion (git rm)
+ _, err = worktree.Remove("to_delete.txt")
+ require.NoError(t, err)
+
+ // Get shadow tree
+ shadowBranch := checkpoint.ShadowBranchNameForCommit("mno7890", "e3b0c4")
+ shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ require.NoError(t, err)
+ shadowTree, err := shadowCommit.Tree()
+ require.NoError(t, err)
+
+ // Deleted file SHOULD count as overlap - the agent's deletion is being committed
+ result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"to_delete.txt"}, []string{"to_delete.txt"})
+ assert.True(t, result, "Deleted file should count as overlap (agent's deletion being committed)")
+}
+
+// createShadowBranchWithContent creates a shadow branch with the given file contents.
+// This helper directly uses go-git APIs to avoid paths.WorktreeRoot() dependency.
+//
+//nolint:unparam // worktreeID is kept as a parameter for flexibility even if tests currently use same value
+func createShadowBranchWithContent(t *testing.T, repo *git.Repository, baseCommit, worktreeID string, fileContents map[string][]byte) {
+ t.Helper()
+
+ shadowBranchName := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+
+ // Get HEAD for base tree
+ head, err := repo.Head()
+ require.NoError(t, err)
+
+ headCommit, err := repo.CommitObject(head.Hash())
+ require.NoError(t, err)
+
+ baseTree, err := headCommit.Tree()
+ require.NoError(t, err)
+
+ // Flatten existing tree into map
+ entries := make(map[string]object.TreeEntry)
+ err = checkpoint.FlattenTree(repo, baseTree, "", entries)
+ require.NoError(t, err)
+
+ // Add/update files with provided content
+ for filePath, content := range fileContents {
+ // Create blob with content
+ blob := repo.Storer.NewEncodedObject()
+ blob.SetType(plumbing.BlobObject)
+ blob.SetSize(int64(len(content)))
+ writer, err := blob.Writer()
+ require.NoError(t, err)
+ _, err = writer.Write(content)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ blobHash, err := repo.Storer.SetEncodedObject(blob)
+ require.NoError(t, err)
+
+ entries[filePath] = object.TreeEntry{
+ Name: filePath,
+ Mode: filemode.Regular,
+ Hash: blobHash,
+ }
+ }
+
+ // Build tree from entries
+ treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries)
+ require.NoError(t, err)
+
+ // Create commit
+ commit := &object.Commit{
+ TreeHash: treeHash,
+ Message: "Test checkpoint",
+ Author: object.Signature{
+ Name: "Test",
+ Email: "test@test.com",
+ When: time.Now(),
+ },
+ Committer: object.Signature{
+ Name: "Test",
+ Email: "test@test.com",
+ When: time.Now(),
+ },
+ }
+
+ commitObj := repo.Storer.NewEncodedObject()
+ err = commit.Encode(commitObj)
+ require.NoError(t, err)
+
+ commitHash, err := repo.Storer.SetEncodedObject(commitObj)
+ require.NoError(t, err)
+
+ // Create branch reference
+ newRef := plumbing.NewHashReference(refName, commitHash)
+ err = repo.Storer.SetReference(newRef)
+ require.NoError(t, err)
+}
+
+// TestExtractSignificantLines tests the line extraction with length-based filtering.
+// Lines must be >= 10 characters after trimming whitespace.
+func TestExtractSignificantLines(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ content string
+ wantKeys []string // lines that should be in the result
+ wantNot []string // lines that should NOT be in the result
+ }{
+ {
+ name: "go function",
+ content: `package main
+
+func hello() {
+ fmt.Println("hello world")
+ return
+}`,
+ wantKeys: []string{
+ "package main", // 12 chars
+ "func hello() {", // 14 chars
+ `fmt.Println("hello world")`, // 26 chars
+ },
+ wantNot: []string{
+ "}", // 1 char
+ "return", // 6 chars
+ },
+ },
+ {
+ name: "python function",
+ content: `def calculate(x, y):
+ result = x + y
+ print(f"Result: {result}")
+ return result`,
+ wantKeys: []string{
+ "def calculate(x, y):", // 20 chars
+ "result = x + y", // 14 chars
+ `print(f"Result: {result}")`, // 25 chars
+ "return result", // 13 chars
+ },
+ wantNot: []string{},
+ },
+ {
+ name: "javascript",
+ content: `const handler = async (req) => {
+ const data = await fetch(url);
+ return data.json();
+};`,
+ wantKeys: []string{
+ "const handler = async (req) => {", // 32 chars
+ "const data = await fetch(url);", // 30 chars
+ "return data.json();", // 19 chars
+ },
+ wantNot: []string{
+ "};", // 2 chars
+ },
+ },
+ {
+ name: "short lines filtered",
+ content: `a = 1
+b = 2
+longVariableName = 42`,
+ wantKeys: []string{
+ "longVariableName = 42", // 21 chars
+ },
+ wantNot: []string{
+ "a = 1", // 5 chars
+ "b = 2", // 5 chars
+ },
+ },
+ {
+ name: "structural lines filtered by length",
+ content: `{
+ });
+ ]);
+ },
+}`,
+ wantKeys: []string{},
+ wantNot: []string{
+ "{", // 1 char
+ "});", // 3 chars
+ "]);", // 3 chars
+ "},", // 2 chars
+ "}", // 1 char
+ },
+ },
+ {
+ name: "regex and special chars kept if long enough",
+ content: `short
+/^[a-z0-9]+@[a-z]+\.[a-z]{2,}$/
+x`,
+ wantKeys: []string{
+ "/^[a-z0-9]+@[a-z]+\\.[a-z]{2,}$/", // 32 chars - kept even though mostly non-alpha
+ },
+ wantNot: []string{
+ "short", // 5 chars
+ "x", // 1 char
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result := extractSignificantLines(tt.content)
+
+ for _, want := range tt.wantKeys {
+ if !result[want] {
+ t.Errorf("extractSignificantLines() missing expected line: %q", want)
+ }
+ }
+
+ for _, notWant := range tt.wantNot {
+ if result[notWant] {
+ t.Errorf("extractSignificantLines() should not contain: %q", notWant)
+ }
+ }
+ })
+ }
+}
+
+// TestHasSignificantContentOverlap tests the content overlap detection logic.
+// We require at least 2 matching significant lines to count as overlap.
+func TestHasSignificantContentOverlap(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ stagedContent string
+ shadowContent string
+ wantOverlap bool
+ }{
+ {
+ name: "two matching significant lines - overlap",
+ stagedContent: "this is a significant line\nanother matching line here\nshort",
+ shadowContent: "this is a significant line\nanother matching line here\nother",
+ wantOverlap: true,
+ },
+ {
+ name: "only one matching significant line - no overlap",
+ stagedContent: "this is a significant line\ncompletely different staged",
+ shadowContent: "this is a significant line\ncompletely different shadow",
+ wantOverlap: false,
+ },
+ {
+ name: "no matching significant lines",
+ stagedContent: "completely different content here",
+ shadowContent: "this is the shadow content now",
+ wantOverlap: false,
+ },
+ {
+ name: "both have only short lines - no significant content",
+ stagedContent: "a = 1\nb = 2\nc = 3",
+ shadowContent: "x = 1\ny = 2\nz = 3",
+ wantOverlap: false,
+ },
+ {
+ name: "shadow has significant lines but staged has none",
+ stagedContent: "a = 1\nb = 2",
+ shadowContent: "this is significant content from shadow",
+ wantOverlap: false,
+ },
+ {
+ name: "staged has significant lines but shadow has none",
+ stagedContent: "this is significant content from staged",
+ shadowContent: "x = 1\ny = 2",
+ wantOverlap: false,
+ },
+ {
+ name: "empty strings",
+ stagedContent: "",
+ shadowContent: "",
+ wantOverlap: false,
+ },
+ {
+ name: "single shared line like package main - no overlap (boilerplate)",
+ stagedContent: "package main\nfunc NewImplementation() {}",
+ shadowContent: "package main\nfunc OriginalCode() {}",
+ wantOverlap: false,
+ },
+ {
+ name: "multiple shared lines - overlap (user kept agent work)",
+ stagedContent: "package main\nfunc SharedFunction() {\nreturn nil",
+ shadowContent: "package main\nfunc SharedFunction() {\nreturn nil",
+ wantOverlap: true,
+ },
+ {
+ name: "very small file with single match - overlap (small file exception)",
+ stagedContent: "this is a unique line here\nshort",
+ shadowContent: "this is a unique line here\nshort",
+ wantOverlap: true, // Shadow has only 1 significant line, so 1 match counts
+ },
+ {
+ name: "very small file no match - no overlap",
+ stagedContent: "completely different staged content",
+ shadowContent: "short",
+ wantOverlap: false, // Shadow is very small but no matching lines
+ },
+ {
+ name: "large staged vs very small shadow with single match - overlap",
+ stagedContent: "line one here\nline two here\nline three here\nshared content line",
+ shadowContent: "shared content line\nshort",
+ wantOverlap: true, // Shadow has only 1 significant line, so 1 match counts
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := hasSignificantContentOverlap(tt.stagedContent, tt.shadowContent)
+ if got != tt.wantOverlap {
+ t.Errorf("hasSignificantContentOverlap() = %v, want %v", got, tt.wantOverlap)
+ }
+ })
+ }
+}
+
+// TestTrimLine tests whitespace trimming from lines.
+func TestTrimLine(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ line string
+ want string
+ }{
+ {"no whitespace", "hello", "hello"},
+ {"leading spaces", " hello", "hello"},
+ {"trailing spaces", "hello ", "hello"},
+ {"both leading and trailing spaces", " hello ", "hello"},
+ {"leading tabs", "\t\thello", "hello"},
+ {"trailing tabs", "hello\t\t", "hello"},
+ {"mixed whitespace", " \t hello \t ", "hello"},
+ {"only spaces", " ", ""},
+ {"only tabs", "\t\t\t", ""},
+ {"empty string", "", ""},
+ {"spaces in middle preserved", "hello world", "hello world"},
+ {"tabs in middle preserved", "hello\tworld", "hello\tworld"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := trimLine(tt.line)
+ if got != tt.want {
+ t.Errorf("trimLine(%q) = %q, want %q", tt.line, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/cli/strategy/content_overlap_test.go b/cli/strategy/content_overlap_test.go
index 45e227b..1f162e7 100644
--- a/cli/strategy/content_overlap_test.go
+++ b/cli/strategy/content_overlap_test.go
@@ -10,7 +10,6 @@ import (
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
- "github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -791,534 +790,3 @@ func TestFilesWithRemainingAgentChanges_UncommittedDeletion(t *testing.T) {
// buildTreeWithChanges would just see the file is missing and record a no-op.
assert.Empty(t, remaining, "Deleted file not in shadow tree should not be carried forward")
}
-
-// TestStagedFilesOverlapWithContent_ModifiedFile tests that a modified file
-// (exists in HEAD) always counts as overlap.
-func TestStagedFilesOverlapWithContent_ModifiedFile(t *testing.T) {
- t.Parallel()
- dir := setupGitRepo(t)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- // Initial file is created by setupGitRepo
- // Modify it and stage
- testFile := filepath.Join(dir, "test.txt")
- require.NoError(t, os.WriteFile(testFile, []byte("modified content"), 0o644))
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
-
- // Create shadow branch (content doesn't matter for modified files)
- createShadowBranchWithContent(t, repo, "abc1234", "e3b0c4", map[string][]byte{
- "test.txt": []byte("shadow content"),
- })
-
- // Get shadow tree
- shadowBranch := checkpoint.ShadowBranchNameForCommit("abc1234", "e3b0c4")
- shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- require.NoError(t, err)
- shadowTree, err := shadowCommit.Tree()
- require.NoError(t, err)
-
- // Modified file should count as overlap regardless of content
- result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"test.txt"}, []string{"test.txt"})
- assert.True(t, result, "Modified file should always count as overlap")
-}
-
-// TestStagedFilesOverlapWithContent_NewFile_ContentMatch tests that a new file
-// with matching content counts as overlap.
-func TestStagedFilesOverlapWithContent_NewFile_ContentMatch(t *testing.T) {
- t.Parallel()
- dir := setupGitRepo(t)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- // Create a NEW file (doesn't exist in HEAD)
- content := []byte("new file content")
- newFile := filepath.Join(dir, "newfile.txt")
- require.NoError(t, os.WriteFile(newFile, content, 0o644))
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("newfile.txt")
- require.NoError(t, err)
-
- // Create shadow branch with SAME content
- createShadowBranchWithContent(t, repo, "def5678", "e3b0c4", map[string][]byte{
- "newfile.txt": content,
- })
-
- // Get shadow tree
- shadowBranch := checkpoint.ShadowBranchNameForCommit("def5678", "e3b0c4")
- shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- require.NoError(t, err)
- shadowTree, err := shadowCommit.Tree()
- require.NoError(t, err)
-
- // New file with matching content should count as overlap
- result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"newfile.txt"}, []string{"newfile.txt"})
- assert.True(t, result, "New file with matching content should count as overlap")
-}
-
-// TestStagedFilesOverlapWithContent_NewFile_ContentMismatch tests that a new file
-// with different content does NOT count as overlap (reverted & replaced scenario).
-func TestStagedFilesOverlapWithContent_NewFile_ContentMismatch(t *testing.T) {
- t.Parallel()
- dir := setupGitRepo(t)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- // Create a NEW file with different content than shadow branch
- newFile := filepath.Join(dir, "newfile.txt")
- require.NoError(t, os.WriteFile(newFile, []byte("user replaced content"), 0o644))
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("newfile.txt")
- require.NoError(t, err)
-
- // Create shadow branch with DIFFERENT content (agent's original)
- createShadowBranchWithContent(t, repo, "ghi9012", "e3b0c4", map[string][]byte{
- "newfile.txt": []byte("agent original content"),
- })
-
- // Get shadow tree
- shadowBranch := checkpoint.ShadowBranchNameForCommit("ghi9012", "e3b0c4")
- shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- require.NoError(t, err)
- shadowTree, err := shadowCommit.Tree()
- require.NoError(t, err)
-
- // New file with different content should NOT count as overlap
- result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"newfile.txt"}, []string{"newfile.txt"})
- assert.False(t, result, "New file with mismatched content should not count as overlap")
-}
-
-// TestStagedFilesOverlapWithContent_NoOverlap tests that non-overlapping files
-// return false.
-func TestStagedFilesOverlapWithContent_NoOverlap(t *testing.T) {
- t.Parallel()
- dir := setupGitRepo(t)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- // Stage a file NOT in filesTouched
- otherFile := filepath.Join(dir, "other.txt")
- require.NoError(t, os.WriteFile(otherFile, []byte("other content"), 0o644))
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("other.txt")
- require.NoError(t, err)
-
- // Create shadow branch
- createShadowBranchWithContent(t, repo, "jkl3456", "e3b0c4", map[string][]byte{
- "session.txt": []byte("session content"),
- })
-
- // Get shadow tree
- shadowBranch := checkpoint.ShadowBranchNameForCommit("jkl3456", "e3b0c4")
- shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- require.NoError(t, err)
- shadowTree, err := shadowCommit.Tree()
- require.NoError(t, err)
-
- // Staged file "other.txt" is not in filesTouched "session.txt"
- result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"other.txt"}, []string{"session.txt"})
- assert.False(t, result, "Non-overlapping files should return false")
-}
-
-// TestStagedFilesOverlapWithContent_DeletedFile tests that a deleted file
-// (exists in HEAD but staged for deletion) DOES count as overlap.
-// The agent's action of deleting the file is being committed, so the session
-// context should be linked to this commit.
-func TestStagedFilesOverlapWithContent_DeletedFile(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- require.NoError(t, err)
-
- // Create and commit a file that will be deleted
- filePath := filepath.Join(dir, "to_delete.txt")
- err = os.WriteFile(filePath, []byte("original content"), 0o644)
- require.NoError(t, err)
- _, err = worktree.Add("to_delete.txt")
- require.NoError(t, err)
- _, err = worktree.Commit("Add to_delete.txt", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@test.com",
- When: time.Now(),
- },
- })
- require.NoError(t, err)
-
- // Create shadow branch (simulating agent work on the file)
- createShadowBranchWithContent(t, repo, "mno7890", "e3b0c4", map[string][]byte{
- "to_delete.txt": []byte("agent modified content"),
- })
-
- // Stage the file for deletion (git rm)
- _, err = worktree.Remove("to_delete.txt")
- require.NoError(t, err)
-
- // Get shadow tree
- shadowBranch := checkpoint.ShadowBranchNameForCommit("mno7890", "e3b0c4")
- shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- require.NoError(t, err)
- shadowTree, err := shadowCommit.Tree()
- require.NoError(t, err)
-
- // Deleted file SHOULD count as overlap - the agent's deletion is being committed
- result := stagedFilesOverlapWithContent(context.Background(), repo, shadowTree, []string{"to_delete.txt"}, []string{"to_delete.txt"})
- assert.True(t, result, "Deleted file should count as overlap (agent's deletion being committed)")
-}
-
-// createShadowBranchWithContent creates a shadow branch with the given file contents.
-// This helper directly uses go-git APIs to avoid paths.WorktreeRoot() dependency.
-//
-//nolint:unparam // worktreeID is kept as a parameter for flexibility even if tests currently use same value
-func createShadowBranchWithContent(t *testing.T, repo *git.Repository, baseCommit, worktreeID string, fileContents map[string][]byte) {
- t.Helper()
-
- shadowBranchName := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
-
- // Get HEAD for base tree
- head, err := repo.Head()
- require.NoError(t, err)
-
- headCommit, err := repo.CommitObject(head.Hash())
- require.NoError(t, err)
-
- baseTree, err := headCommit.Tree()
- require.NoError(t, err)
-
- // Flatten existing tree into map
- entries := make(map[string]object.TreeEntry)
- err = checkpoint.FlattenTree(repo, baseTree, "", entries)
- require.NoError(t, err)
-
- // Add/update files with provided content
- for filePath, content := range fileContents {
- // Create blob with content
- blob := repo.Storer.NewEncodedObject()
- blob.SetType(plumbing.BlobObject)
- blob.SetSize(int64(len(content)))
- writer, err := blob.Writer()
- require.NoError(t, err)
- _, err = writer.Write(content)
- require.NoError(t, err)
- err = writer.Close()
- require.NoError(t, err)
-
- blobHash, err := repo.Storer.SetEncodedObject(blob)
- require.NoError(t, err)
-
- entries[filePath] = object.TreeEntry{
- Name: filePath,
- Mode: filemode.Regular,
- Hash: blobHash,
- }
- }
-
- // Build tree from entries
- treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries)
- require.NoError(t, err)
-
- // Create commit
- commit := &object.Commit{
- TreeHash: treeHash,
- Message: "Test checkpoint",
- Author: object.Signature{
- Name: "Test",
- Email: "test@test.com",
- When: time.Now(),
- },
- Committer: object.Signature{
- Name: "Test",
- Email: "test@test.com",
- When: time.Now(),
- },
- }
-
- commitObj := repo.Storer.NewEncodedObject()
- err = commit.Encode(commitObj)
- require.NoError(t, err)
-
- commitHash, err := repo.Storer.SetEncodedObject(commitObj)
- require.NoError(t, err)
-
- // Create branch reference
- newRef := plumbing.NewHashReference(refName, commitHash)
- err = repo.Storer.SetReference(newRef)
- require.NoError(t, err)
-}
-
-// TestExtractSignificantLines tests the line extraction with length-based filtering.
-// Lines must be >= 10 characters after trimming whitespace.
-func TestExtractSignificantLines(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- content string
- wantKeys []string // lines that should be in the result
- wantNot []string // lines that should NOT be in the result
- }{
- {
- name: "go function",
- content: `package main
-
-func hello() {
- fmt.Println("hello world")
- return
-}`,
- wantKeys: []string{
- "package main", // 12 chars
- "func hello() {", // 14 chars
- `fmt.Println("hello world")`, // 26 chars
- },
- wantNot: []string{
- "}", // 1 char
- "return", // 6 chars
- },
- },
- {
- name: "python function",
- content: `def calculate(x, y):
- result = x + y
- print(f"Result: {result}")
- return result`,
- wantKeys: []string{
- "def calculate(x, y):", // 20 chars
- "result = x + y", // 14 chars
- `print(f"Result: {result}")`, // 25 chars
- "return result", // 13 chars
- },
- wantNot: []string{},
- },
- {
- name: "javascript",
- content: `const handler = async (req) => {
- const data = await fetch(url);
- return data.json();
-};`,
- wantKeys: []string{
- "const handler = async (req) => {", // 32 chars
- "const data = await fetch(url);", // 30 chars
- "return data.json();", // 19 chars
- },
- wantNot: []string{
- "};", // 2 chars
- },
- },
- {
- name: "short lines filtered",
- content: `a = 1
-b = 2
-longVariableName = 42`,
- wantKeys: []string{
- "longVariableName = 42", // 21 chars
- },
- wantNot: []string{
- "a = 1", // 5 chars
- "b = 2", // 5 chars
- },
- },
- {
- name: "structural lines filtered by length",
- content: `{
- });
- ]);
- },
-}`,
- wantKeys: []string{},
- wantNot: []string{
- "{", // 1 char
- "});", // 3 chars
- "]);", // 3 chars
- "},", // 2 chars
- "}", // 1 char
- },
- },
- {
- name: "regex and special chars kept if long enough",
- content: `short
-/^[a-z0-9]+@[a-z]+\.[a-z]{2,}$/
-x`,
- wantKeys: []string{
- "/^[a-z0-9]+@[a-z]+\\.[a-z]{2,}$/", // 32 chars - kept even though mostly non-alpha
- },
- wantNot: []string{
- "short", // 5 chars
- "x", // 1 char
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- result := extractSignificantLines(tt.content)
-
- for _, want := range tt.wantKeys {
- if !result[want] {
- t.Errorf("extractSignificantLines() missing expected line: %q", want)
- }
- }
-
- for _, notWant := range tt.wantNot {
- if result[notWant] {
- t.Errorf("extractSignificantLines() should not contain: %q", notWant)
- }
- }
- })
- }
-}
-
-// TestHasSignificantContentOverlap tests the content overlap detection logic.
-// We require at least 2 matching significant lines to count as overlap.
-func TestHasSignificantContentOverlap(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- stagedContent string
- shadowContent string
- wantOverlap bool
- }{
- {
- name: "two matching significant lines - overlap",
- stagedContent: "this is a significant line\nanother matching line here\nshort",
- shadowContent: "this is a significant line\nanother matching line here\nother",
- wantOverlap: true,
- },
- {
- name: "only one matching significant line - no overlap",
- stagedContent: "this is a significant line\ncompletely different staged",
- shadowContent: "this is a significant line\ncompletely different shadow",
- wantOverlap: false,
- },
- {
- name: "no matching significant lines",
- stagedContent: "completely different content here",
- shadowContent: "this is the shadow content now",
- wantOverlap: false,
- },
- {
- name: "both have only short lines - no significant content",
- stagedContent: "a = 1\nb = 2\nc = 3",
- shadowContent: "x = 1\ny = 2\nz = 3",
- wantOverlap: false,
- },
- {
- name: "shadow has significant lines but staged has none",
- stagedContent: "a = 1\nb = 2",
- shadowContent: "this is significant content from shadow",
- wantOverlap: false,
- },
- {
- name: "staged has significant lines but shadow has none",
- stagedContent: "this is significant content from staged",
- shadowContent: "x = 1\ny = 2",
- wantOverlap: false,
- },
- {
- name: "empty strings",
- stagedContent: "",
- shadowContent: "",
- wantOverlap: false,
- },
- {
- name: "single shared line like package main - no overlap (boilerplate)",
- stagedContent: "package main\nfunc NewImplementation() {}",
- shadowContent: "package main\nfunc OriginalCode() {}",
- wantOverlap: false,
- },
- {
- name: "multiple shared lines - overlap (user kept agent work)",
- stagedContent: "package main\nfunc SharedFunction() {\nreturn nil",
- shadowContent: "package main\nfunc SharedFunction() {\nreturn nil",
- wantOverlap: true,
- },
- {
- name: "very small file with single match - overlap (small file exception)",
- stagedContent: "this is a unique line here\nshort",
- shadowContent: "this is a unique line here\nshort",
- wantOverlap: true, // Shadow has only 1 significant line, so 1 match counts
- },
- {
- name: "very small file no match - no overlap",
- stagedContent: "completely different staged content",
- shadowContent: "short",
- wantOverlap: false, // Shadow is very small but no matching lines
- },
- {
- name: "large staged vs very small shadow with single match - overlap",
- stagedContent: "line one here\nline two here\nline three here\nshared content line",
- shadowContent: "shared content line\nshort",
- wantOverlap: true, // Shadow has only 1 significant line, so 1 match counts
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := hasSignificantContentOverlap(tt.stagedContent, tt.shadowContent)
- if got != tt.wantOverlap {
- t.Errorf("hasSignificantContentOverlap() = %v, want %v", got, tt.wantOverlap)
- }
- })
- }
-}
-
-// TestTrimLine tests whitespace trimming from lines.
-func TestTrimLine(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- line string
- want string
- }{
- {"no whitespace", "hello", "hello"},
- {"leading spaces", " hello", "hello"},
- {"trailing spaces", "hello ", "hello"},
- {"both leading and trailing spaces", " hello ", "hello"},
- {"leading tabs", "\t\thello", "hello"},
- {"trailing tabs", "hello\t\t", "hello"},
- {"mixed whitespace", " \t hello \t ", "hello"},
- {"only spaces", " ", ""},
- {"only tabs", "\t\t\t", ""},
- {"empty string", "", ""},
- {"spaces in middle preserved", "hello world", "hello world"},
- {"tabs in middle preserved", "hello\tworld", "hello\tworld"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := trimLine(tt.line)
- if got != tt.want {
- t.Errorf("trimLine(%q) = %q, want %q", tt.line, got, tt.want)
- }
- })
- }
-}
diff --git a/cli/strategy/hooks_2_test.go b/cli/strategy/hooks_2_test.go
new file mode 100644
index 0000000..db91e88
--- /dev/null
+++ b/cli/strategy/hooks_2_test.go
@@ -0,0 +1,613 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/paths"
+)
+
+func TestRemoveGitHook_RemovesInstalledHooks(t *testing.T) {
+ tmpDir, _ := initHooksTestRepo(t)
+
+ // Install hooks first
+ installCount, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+ if installCount == 0 {
+ t.Fatal("InstallGitHook() should install hooks")
+ }
+
+ // Verify hooks are installed
+ if !IsGitHookInstalled(context.Background()) {
+ t.Fatal("hooks should be installed before removal test")
+ }
+
+ // Remove hooks
+ removeCount, err := RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+ if removeCount != installCount {
+ t.Errorf("RemoveGitHook(context.Background()) returned %d, want %d (same as installed)", removeCount, installCount)
+ }
+
+ // Verify hooks are removed
+ if IsGitHookInstalled(context.Background()) {
+ t.Error("hooks should not be installed after removal")
+ }
+
+ // Verify hook files no longer exist
+ hooksDir := filepath.Join(tmpDir, ".git", "hooks")
+ for _, hookName := range gitHookNames {
+ hookPath := filepath.Join(hooksDir, hookName)
+ if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
+ t.Errorf("hook file %s should not exist after removal", hookName)
+ }
+ }
+}
+
+func TestRemoveGitHook_NoHooksInstalled(t *testing.T) {
+ initHooksTestRepo(t)
+
+ // Remove hooks when none are installed - should handle gracefully
+ removeCount, err := RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+ if removeCount != 0 {
+ t.Errorf("RemoveGitHook(context.Background()) returned %d, want 0 (no hooks to remove)", removeCount)
+ }
+}
+
+func TestRemoveGitHook_IgnoresNonTraceHooks(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a non-Trace hook manually
+ customHookPath := filepath.Join(hooksDir, "pre-commit")
+ customHookContent := "#!/bin/sh\necho 'custom hook'"
+ if err := os.WriteFile(customHookPath, []byte(customHookContent), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ // Remove hooks - should not remove the custom hook
+ removeCount, err := RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+ if removeCount != 0 {
+ t.Errorf("RemoveGitHook(context.Background()) returned %d, want 0 (custom hook should not be removed)", removeCount)
+ }
+
+ // Verify custom hook still exists
+ if _, err := os.Stat(customHookPath); os.IsNotExist(err) {
+ t.Error("custom hook should still exist after RemoveGitHook(context.Background())")
+ }
+}
+
+func TestRemoveGitHook_NotAGitRepo(t *testing.T) {
+ // Create a temp directory without git init
+ tmpDir := t.TempDir()
+ t.Chdir(tmpDir)
+
+ // Clear cache so paths resolve correctly
+ paths.ClearWorktreeRootCache()
+
+ // Remove hooks in non-git directory - should return error
+ _, err := RemoveGitHook(context.Background())
+ if err == nil {
+ t.Fatal("RemoveGitHook(context.Background()) should return error for non-git directory")
+ }
+}
+
+func TestInstallGitHook_BacksUpCustomHook(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a custom prepare-commit-msg hook
+ customHookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ customContent := "#!/bin/sh\necho 'my custom hook'\n"
+ if err := os.WriteFile(customHookPath, []byte(customContent), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ count, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+ if count == 0 {
+ t.Error("InstallGitHook() should install hooks")
+ }
+
+ // Verify custom hook was backed up
+ backupPath := customHookPath + backupSuffix
+ backupData, err := os.ReadFile(backupPath)
+ if err != nil {
+ t.Fatalf("backup file should exist at %s: %v", backupPath, err)
+ }
+ if string(backupData) != customContent {
+ t.Errorf("backup content = %q, want %q", string(backupData), customContent)
+ }
+
+ // Verify installed hook has our marker and chain call
+ hookData, err := os.ReadFile(customHookPath)
+ if err != nil {
+ t.Fatalf("hook file should exist: %v", err)
+ }
+ hookContent := string(hookData)
+ if !strings.Contains(hookContent, traceHookMarker) {
+ t.Error("installed hook should contain Trace marker")
+ }
+ if !strings.Contains(hookContent, chainComment) {
+ t.Error("installed hook should contain chain call")
+ }
+ if !strings.Contains(hookContent, "prepare-commit-msg"+backupSuffix) {
+ t.Error("chain call should reference the backup file")
+ }
+}
+
+func TestManagedGitHookNames_IncludesPostRewrite(t *testing.T) {
+ t.Parallel()
+
+ names := ManagedGitHookNames()
+ if !slices.Contains(names, "post-rewrite") {
+ t.Fatalf("ManagedGitHookNames() = %v, want post-rewrite included", names)
+ }
+}
+
+func TestInstallGitHook_InstallsPostRewrite(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ count, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+ if count == 0 {
+ t.Fatal("InstallGitHook() should install hooks")
+ }
+
+ hookPath := filepath.Join(hooksDir, "post-rewrite")
+ hookData, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatalf("post-rewrite hook should exist: %v", err)
+ }
+
+ hookContent := string(hookData)
+ if !strings.Contains(hookContent, traceHookMarker) {
+ t.Error("installed post-rewrite hook should contain Trace marker")
+ }
+ if !strings.Contains(hookContent, `trace hooks git post-rewrite "$1" 2>>".git/trace-hooks.log" || true`) {
+ t.Errorf("installed post-rewrite hook content missing expected command:\n%s", hookContent)
+ }
+}
+
+func TestInstallGitHook_DoesNotOverwriteExistingBackup(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a backup file manually (simulating a previous backup)
+ firstBackupContent := "#!/bin/sh\necho 'first custom hook'\n"
+ backupPath := filepath.Join(hooksDir, "prepare-commit-msg"+backupSuffix)
+ if err := os.WriteFile(backupPath, []byte(firstBackupContent), 0o755); err != nil {
+ t.Fatalf("failed to create backup: %v", err)
+ }
+
+ // Create a second custom hook at the standard path
+ secondCustomContent := "#!/bin/sh\necho 'second custom hook'\n"
+ hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ if err := os.WriteFile(hookPath, []byte(secondCustomContent), 0o755); err != nil {
+ t.Fatalf("failed to create second custom hook: %v", err)
+ }
+
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // Verify the original backup was NOT overwritten
+ backupData, err := os.ReadFile(backupPath)
+ if err != nil {
+ t.Fatalf("backup should still exist: %v", err)
+ }
+ if string(backupData) != firstBackupContent {
+ t.Errorf("backup content = %q, want original %q", string(backupData), firstBackupContent)
+ }
+
+ // Verify our hook was installed with chain call
+ hookData, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatalf("hook should exist: %v", err)
+ }
+ if !strings.Contains(string(hookData), traceHookMarker) {
+ t.Error("hook should contain Trace marker")
+ }
+ if !strings.Contains(string(hookData), chainComment) {
+ t.Error("hook should contain chain call since backup exists")
+ }
+}
+
+func TestInstallGitHook_IdempotentWithChaining(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a custom hook, then install
+ customHookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ if err := os.WriteFile(customHookPath, []byte("#!/bin/sh\necho custom\n"), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ firstCount, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("first InstallGitHook() error = %v", err)
+ }
+ if firstCount == 0 {
+ t.Error("first install should install hooks")
+ }
+
+ // Re-install should return 0 (idempotent)
+ secondCount, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("second InstallGitHook() error = %v", err)
+ }
+ if secondCount != 0 {
+ t.Errorf("second InstallGitHook() = %d, want 0 (idempotent)", secondCount)
+ }
+}
+
+func TestInstallGitHook_NoBackupWhenNoExistingHook(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // No .pre-trace files should exist
+ for _, hook := range gitHookNames {
+ backupPath := filepath.Join(hooksDir, hook+backupSuffix)
+ if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
+ t.Errorf("backup %s should not exist for fresh install", hook+backupSuffix)
+ }
+
+ // Hook should not contain chain call
+ data, err := os.ReadFile(filepath.Join(hooksDir, hook))
+ if err != nil {
+ t.Fatalf("hook %s should exist: %v", hook, err)
+ }
+ if strings.Contains(string(data), chainComment) {
+ t.Errorf("hook %s should not contain chain call for fresh install", hook)
+ }
+ }
+}
+
+func TestInstallGitHook_MixedHooks(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Only create custom hooks for some hooks
+ customHooks := map[string]string{
+ "prepare-commit-msg": "#!/bin/sh\necho 'custom pcm'\n",
+ "pre-push": "#!/bin/sh\necho 'custom prepush'\n",
+ }
+ for name, content := range customHooks {
+ hookPath := filepath.Join(hooksDir, name)
+ if err := os.WriteFile(hookPath, []byte(content), 0o755); err != nil {
+ t.Fatalf("failed to create %s: %v", name, err)
+ }
+ }
+
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // Hooks with pre-existing content should have backups and chain calls
+ for name := range customHooks {
+ backupPath := filepath.Join(hooksDir, name+backupSuffix)
+ if _, err := os.Stat(backupPath); os.IsNotExist(err) {
+ t.Errorf("backup for %s should exist", name)
+ }
+
+ data, err := os.ReadFile(filepath.Join(hooksDir, name))
+ if err != nil {
+ t.Fatalf("hook %s should exist: %v", name, err)
+ }
+ if !strings.Contains(string(data), chainComment) {
+ t.Errorf("hook %s should contain chain call", name)
+ }
+ }
+
+ // Hooks without pre-existing content should NOT have backups or chain calls
+ noCustom := []string{"commit-msg", "post-commit"}
+ for _, name := range noCustom {
+ backupPath := filepath.Join(hooksDir, name+backupSuffix)
+ if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
+ t.Errorf("backup for %s should NOT exist", name)
+ }
+
+ data, err := os.ReadFile(filepath.Join(hooksDir, name))
+ if err != nil {
+ t.Fatalf("hook %s should exist: %v", name, err)
+ }
+ if strings.Contains(string(data), chainComment) {
+ t.Errorf("hook %s should NOT contain chain call", name)
+ }
+ }
+}
+
+func TestRemoveGitHook_RestoresBackup(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a custom hook, install (backs it up), then remove
+ customContent := "#!/bin/sh\necho 'my custom hook'\n"
+ hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ removed, err := RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+ if removed == 0 {
+ t.Error("RemoveGitHook(context.Background()) should remove hooks")
+ }
+
+ // Original custom hook should be restored
+ data, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatalf("hook should be restored: %v", err)
+ }
+ if string(data) != customContent {
+ t.Errorf("restored hook content = %q, want %q", string(data), customContent)
+ }
+
+ // Backup should be gone
+ backupPath := hookPath + backupSuffix
+ if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
+ t.Error("backup should be removed after restore")
+ }
+}
+
+func TestRemoveGitHook_RestoresBackupWhenHookAlreadyGone(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create custom hook, install (creates backup), then delete the main hook
+ customContent := "#!/bin/sh\necho 'original'\n"
+ hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // Simulate another tool deleting our hook
+ if err := os.Remove(hookPath); err != nil {
+ t.Fatalf("failed to remove hook: %v", err)
+ }
+
+ _, err = RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+
+ // Backup should be restored even though the main hook was already gone
+ data, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatal("backup should be restored to main hook path")
+ }
+ if string(data) != customContent {
+ t.Errorf("restored hook content = %q, want %q", string(data), customContent)
+ }
+
+ // Backup file should be gone
+ backupPath := hookPath + backupSuffix
+ if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
+ t.Error("backup file should not exist after restore")
+ }
+}
+
+func TestGenerateChainedContent(t *testing.T) {
+ t.Parallel()
+
+ base := "#!/bin/sh\n# Trace CLI hooks\ntrace hooks git pre-push \"$1\" || true\n"
+ result := generateChainedContent(base, "pre-push")
+
+ // Should start with the base content
+ if !strings.HasPrefix(result, base) {
+ t.Error("chained content should start with base content")
+ }
+
+ // Should contain the chain comment
+ if !strings.Contains(result, chainComment) {
+ t.Error("chained content should contain chain comment")
+ }
+
+ // Should resolve hook directory from $0
+ if !strings.Contains(result, `_trace_hook_dir="$(dirname "$0")"`) {
+ t.Error("chained content should resolve hook directory from $0")
+ }
+
+ // Should check executable permission on backup
+ expectedCheck := `[ -x "$_trace_hook_dir/pre-push` + backupSuffix + `" ]`
+ if !strings.Contains(result, expectedCheck) {
+ t.Errorf("chained content should check -x on backup, got:\n%s", result)
+ }
+
+ // Should forward all arguments with "$@"
+ expectedExec := `"$_trace_hook_dir/pre-push` + backupSuffix + `" "$@"`
+ if !strings.Contains(result, expectedExec) {
+ t.Errorf("chained content should execute backup with $@, got:\n%s", result)
+ }
+}
+
+func TestGenerateChainedContent_PostRewritePreservesStdinForBackup(t *testing.T) {
+ t.Parallel()
+
+ base := "#!/bin/sh\n# Trace CLI hooks\n# Post-rewrite hook: remap session linkage after amend/rebase rewrites\ntrace hooks git post-rewrite \"$1\" 2>/dev/null || true\n"
+ result := generateChainedContent(base, "post-rewrite")
+
+ if !strings.Contains(result, `_trace_stdin="$(mktemp "${TMPDIR:-/tmp}/trace-post-rewrite.XXXXXX")"`) {
+ t.Fatalf("post-rewrite chained content should create temp stdin copy, got:\n%s", result)
+ }
+ if !strings.Contains(result, `cat > "$_trace_stdin"`) {
+ t.Fatalf("post-rewrite chained content should capture stdin once, got:\n%s", result)
+ }
+ if !strings.Contains(result, `trace hooks git post-rewrite "$1" < "$_trace_stdin" 2>/dev/null || true`) {
+ t.Fatalf("post-rewrite chained content should replay stdin into Trace handler, got:\n%s", result)
+ }
+ if !strings.Contains(result, `"$_trace_hook_dir/post-rewrite`+backupSuffix+`" "$@" < "$_trace_stdin"`) {
+ t.Fatalf("post-rewrite chained content should replay stdin into backup hook, got:\n%s", result)
+ }
+}
+
+func TestInstallGitHook_InstallRemoveReinstall(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // Create a custom hook
+ customContent := "#!/bin/sh\necho 'user hook'\n"
+ hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
+ t.Fatalf("failed to create custom hook: %v", err)
+ }
+
+ // Install: should back up and chain
+ count, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("first install error: %v", err)
+ }
+ if count == 0 {
+ t.Error("first install should install hooks")
+ }
+ backupPath := hookPath + backupSuffix
+ if !fileExists(backupPath) {
+ t.Fatal("backup should exist after install")
+ }
+
+ // Remove: should restore backup
+ _, err = RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("remove error: %v", err)
+ }
+ data, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatal("hook should be restored after remove")
+ }
+ if string(data) != customContent {
+ t.Errorf("restored hook = %q, want %q", string(data), customContent)
+ }
+ if fileExists(backupPath) {
+ t.Error("backup should not exist after remove")
+ }
+
+ // Reinstall: should back up again and chain
+ count, err = InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("reinstall error: %v", err)
+ }
+ if count == 0 {
+ t.Error("reinstall should install hooks")
+ }
+ if !fileExists(backupPath) {
+ t.Fatal("backup should exist after reinstall")
+ }
+ data, err = os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatal("hook should exist after reinstall")
+ }
+ if !strings.Contains(string(data), traceHookMarker) {
+ t.Error("reinstalled hook should contain Trace marker")
+ }
+ if !strings.Contains(string(data), chainComment) {
+ t.Error("reinstalled hook should contain chain call")
+ }
+}
+
+func TestRemoveGitHook_DoesNotOverwriteReplacedHook(t *testing.T) {
+ _, hooksDir := initHooksTestRepo(t)
+
+ // User has custom hook A
+ hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
+ hookAContent := "#!/bin/sh\necho 'hook A'\n"
+ if err := os.WriteFile(hookPath, []byte(hookAContent), 0o755); err != nil {
+ t.Fatalf("failed to create hook A: %v", err)
+ }
+
+ // trace enable: backs up A, installs our hook with chain
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // User replaces our hook with their own hook B
+ hookBContent := "#!/bin/sh\necho 'hook B'\n"
+ if err := os.WriteFile(hookPath, []byte(hookBContent), 0o755); err != nil {
+ t.Fatalf("failed to create hook B: %v", err)
+ }
+
+ // trace disable: should NOT overwrite hook B with backup A
+ _, err = RemoveGitHook(context.Background())
+ if err != nil {
+ t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
+ }
+
+ // Hook B should still be in place
+ data, err := os.ReadFile(hookPath)
+ if err != nil {
+ t.Fatal("hook should still exist")
+ }
+ if string(data) != hookBContent {
+ t.Errorf("hook content = %q, want hook B %q (should not be overwritten by backup)", string(data), hookBContent)
+ }
+
+ // Backup should still exist (not consumed)
+ backupPath := hookPath + backupSuffix
+ if !fileExists(backupPath) {
+ t.Error("backup should be left in place when hook was modified")
+ }
+}
+
+func TestRemoveGitHook_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("Test cannot run as root (permission checks are bypassed)")
+ }
+
+ tmpDir, _ := initHooksTestRepo(t)
+
+ // Install hooks first
+ _, err := InstallGitHook(context.Background(), true, false, false)
+ if err != nil {
+ t.Fatalf("InstallGitHook() error = %v", err)
+ }
+
+ // Remove write permissions from hooks directory to cause permission error
+ hooksDir := filepath.Join(tmpDir, ".git", "hooks")
+ if err := os.Chmod(hooksDir, 0o555); err != nil {
+ t.Fatalf("failed to change hooks dir permissions: %v", err)
+ }
+ // Restore permissions on cleanup
+ t.Cleanup(func() {
+ _ = os.Chmod(hooksDir, 0o755) //nolint:errcheck // Cleanup, best-effort
+ })
+
+ // Remove hooks should now fail with permission error
+ removed, err := RemoveGitHook(context.Background())
+ if err == nil {
+ t.Fatal("RemoveGitHook(context.Background()) should return error when hooks cannot be deleted")
+ }
+ if removed != 0 {
+ t.Errorf("RemoveGitHook(context.Background()) removed %d hooks, expected 0 when all fail", removed)
+ }
+ if !strings.Contains(err.Error(), "failed to remove hooks") {
+ t.Errorf("error should mention 'failed to remove hooks', got: %v", err)
+ }
+}
diff --git a/cli/strategy/hooks_test.go b/cli/strategy/hooks_test.go
index 317df19..b66f16a 100644
--- a/cli/strategy/hooks_test.go
+++ b/cli/strategy/hooks_test.go
@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
- "slices"
"strings"
"testing"
@@ -790,604 +789,3 @@ func TestRemoveGitHook_CoreHooksPathRelative(t *testing.T) {
t.Error("IsGitHookInstalledInDir() should be false after removing hooks in core.hooksPath")
}
}
-
-func TestRemoveGitHook_RemovesInstalledHooks(t *testing.T) {
- tmpDir, _ := initHooksTestRepo(t)
-
- // Install hooks first
- installCount, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
- if installCount == 0 {
- t.Fatal("InstallGitHook() should install hooks")
- }
-
- // Verify hooks are installed
- if !IsGitHookInstalled(context.Background()) {
- t.Fatal("hooks should be installed before removal test")
- }
-
- // Remove hooks
- removeCount, err := RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
- if removeCount != installCount {
- t.Errorf("RemoveGitHook(context.Background()) returned %d, want %d (same as installed)", removeCount, installCount)
- }
-
- // Verify hooks are removed
- if IsGitHookInstalled(context.Background()) {
- t.Error("hooks should not be installed after removal")
- }
-
- // Verify hook files no longer exist
- hooksDir := filepath.Join(tmpDir, ".git", "hooks")
- for _, hookName := range gitHookNames {
- hookPath := filepath.Join(hooksDir, hookName)
- if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
- t.Errorf("hook file %s should not exist after removal", hookName)
- }
- }
-}
-
-func TestRemoveGitHook_NoHooksInstalled(t *testing.T) {
- initHooksTestRepo(t)
-
- // Remove hooks when none are installed - should handle gracefully
- removeCount, err := RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
- if removeCount != 0 {
- t.Errorf("RemoveGitHook(context.Background()) returned %d, want 0 (no hooks to remove)", removeCount)
- }
-}
-
-func TestRemoveGitHook_IgnoresNonTraceHooks(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a non-Trace hook manually
- customHookPath := filepath.Join(hooksDir, "pre-commit")
- customHookContent := "#!/bin/sh\necho 'custom hook'"
- if err := os.WriteFile(customHookPath, []byte(customHookContent), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- // Remove hooks - should not remove the custom hook
- removeCount, err := RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
- if removeCount != 0 {
- t.Errorf("RemoveGitHook(context.Background()) returned %d, want 0 (custom hook should not be removed)", removeCount)
- }
-
- // Verify custom hook still exists
- if _, err := os.Stat(customHookPath); os.IsNotExist(err) {
- t.Error("custom hook should still exist after RemoveGitHook(context.Background())")
- }
-}
-
-func TestRemoveGitHook_NotAGitRepo(t *testing.T) {
- // Create a temp directory without git init
- tmpDir := t.TempDir()
- t.Chdir(tmpDir)
-
- // Clear cache so paths resolve correctly
- paths.ClearWorktreeRootCache()
-
- // Remove hooks in non-git directory - should return error
- _, err := RemoveGitHook(context.Background())
- if err == nil {
- t.Fatal("RemoveGitHook(context.Background()) should return error for non-git directory")
- }
-}
-
-func TestInstallGitHook_BacksUpCustomHook(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a custom prepare-commit-msg hook
- customHookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- customContent := "#!/bin/sh\necho 'my custom hook'\n"
- if err := os.WriteFile(customHookPath, []byte(customContent), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- count, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
- if count == 0 {
- t.Error("InstallGitHook() should install hooks")
- }
-
- // Verify custom hook was backed up
- backupPath := customHookPath + backupSuffix
- backupData, err := os.ReadFile(backupPath)
- if err != nil {
- t.Fatalf("backup file should exist at %s: %v", backupPath, err)
- }
- if string(backupData) != customContent {
- t.Errorf("backup content = %q, want %q", string(backupData), customContent)
- }
-
- // Verify installed hook has our marker and chain call
- hookData, err := os.ReadFile(customHookPath)
- if err != nil {
- t.Fatalf("hook file should exist: %v", err)
- }
- hookContent := string(hookData)
- if !strings.Contains(hookContent, traceHookMarker) {
- t.Error("installed hook should contain Trace marker")
- }
- if !strings.Contains(hookContent, chainComment) {
- t.Error("installed hook should contain chain call")
- }
- if !strings.Contains(hookContent, "prepare-commit-msg"+backupSuffix) {
- t.Error("chain call should reference the backup file")
- }
-}
-
-func TestManagedGitHookNames_IncludesPostRewrite(t *testing.T) {
- t.Parallel()
-
- names := ManagedGitHookNames()
- if !slices.Contains(names, "post-rewrite") {
- t.Fatalf("ManagedGitHookNames() = %v, want post-rewrite included", names)
- }
-}
-
-func TestInstallGitHook_InstallsPostRewrite(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- count, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
- if count == 0 {
- t.Fatal("InstallGitHook() should install hooks")
- }
-
- hookPath := filepath.Join(hooksDir, "post-rewrite")
- hookData, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatalf("post-rewrite hook should exist: %v", err)
- }
-
- hookContent := string(hookData)
- if !strings.Contains(hookContent, traceHookMarker) {
- t.Error("installed post-rewrite hook should contain Trace marker")
- }
- if !strings.Contains(hookContent, `trace hooks git post-rewrite "$1" 2>>".git/trace-hooks.log" || true`) {
- t.Errorf("installed post-rewrite hook content missing expected command:\n%s", hookContent)
- }
-}
-
-func TestInstallGitHook_DoesNotOverwriteExistingBackup(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a backup file manually (simulating a previous backup)
- firstBackupContent := "#!/bin/sh\necho 'first custom hook'\n"
- backupPath := filepath.Join(hooksDir, "prepare-commit-msg"+backupSuffix)
- if err := os.WriteFile(backupPath, []byte(firstBackupContent), 0o755); err != nil {
- t.Fatalf("failed to create backup: %v", err)
- }
-
- // Create a second custom hook at the standard path
- secondCustomContent := "#!/bin/sh\necho 'second custom hook'\n"
- hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- if err := os.WriteFile(hookPath, []byte(secondCustomContent), 0o755); err != nil {
- t.Fatalf("failed to create second custom hook: %v", err)
- }
-
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // Verify the original backup was NOT overwritten
- backupData, err := os.ReadFile(backupPath)
- if err != nil {
- t.Fatalf("backup should still exist: %v", err)
- }
- if string(backupData) != firstBackupContent {
- t.Errorf("backup content = %q, want original %q", string(backupData), firstBackupContent)
- }
-
- // Verify our hook was installed with chain call
- hookData, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatalf("hook should exist: %v", err)
- }
- if !strings.Contains(string(hookData), traceHookMarker) {
- t.Error("hook should contain Trace marker")
- }
- if !strings.Contains(string(hookData), chainComment) {
- t.Error("hook should contain chain call since backup exists")
- }
-}
-
-func TestInstallGitHook_IdempotentWithChaining(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a custom hook, then install
- customHookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- if err := os.WriteFile(customHookPath, []byte("#!/bin/sh\necho custom\n"), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- firstCount, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("first InstallGitHook() error = %v", err)
- }
- if firstCount == 0 {
- t.Error("first install should install hooks")
- }
-
- // Re-install should return 0 (idempotent)
- secondCount, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("second InstallGitHook() error = %v", err)
- }
- if secondCount != 0 {
- t.Errorf("second InstallGitHook() = %d, want 0 (idempotent)", secondCount)
- }
-}
-
-func TestInstallGitHook_NoBackupWhenNoExistingHook(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // No .pre-trace files should exist
- for _, hook := range gitHookNames {
- backupPath := filepath.Join(hooksDir, hook+backupSuffix)
- if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
- t.Errorf("backup %s should not exist for fresh install", hook+backupSuffix)
- }
-
- // Hook should not contain chain call
- data, err := os.ReadFile(filepath.Join(hooksDir, hook))
- if err != nil {
- t.Fatalf("hook %s should exist: %v", hook, err)
- }
- if strings.Contains(string(data), chainComment) {
- t.Errorf("hook %s should not contain chain call for fresh install", hook)
- }
- }
-}
-
-func TestInstallGitHook_MixedHooks(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Only create custom hooks for some hooks
- customHooks := map[string]string{
- "prepare-commit-msg": "#!/bin/sh\necho 'custom pcm'\n",
- "pre-push": "#!/bin/sh\necho 'custom prepush'\n",
- }
- for name, content := range customHooks {
- hookPath := filepath.Join(hooksDir, name)
- if err := os.WriteFile(hookPath, []byte(content), 0o755); err != nil {
- t.Fatalf("failed to create %s: %v", name, err)
- }
- }
-
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // Hooks with pre-existing content should have backups and chain calls
- for name := range customHooks {
- backupPath := filepath.Join(hooksDir, name+backupSuffix)
- if _, err := os.Stat(backupPath); os.IsNotExist(err) {
- t.Errorf("backup for %s should exist", name)
- }
-
- data, err := os.ReadFile(filepath.Join(hooksDir, name))
- if err != nil {
- t.Fatalf("hook %s should exist: %v", name, err)
- }
- if !strings.Contains(string(data), chainComment) {
- t.Errorf("hook %s should contain chain call", name)
- }
- }
-
- // Hooks without pre-existing content should NOT have backups or chain calls
- noCustom := []string{"commit-msg", "post-commit"}
- for _, name := range noCustom {
- backupPath := filepath.Join(hooksDir, name+backupSuffix)
- if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
- t.Errorf("backup for %s should NOT exist", name)
- }
-
- data, err := os.ReadFile(filepath.Join(hooksDir, name))
- if err != nil {
- t.Fatalf("hook %s should exist: %v", name, err)
- }
- if strings.Contains(string(data), chainComment) {
- t.Errorf("hook %s should NOT contain chain call", name)
- }
- }
-}
-
-func TestRemoveGitHook_RestoresBackup(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a custom hook, install (backs it up), then remove
- customContent := "#!/bin/sh\necho 'my custom hook'\n"
- hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- removed, err := RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
- if removed == 0 {
- t.Error("RemoveGitHook(context.Background()) should remove hooks")
- }
-
- // Original custom hook should be restored
- data, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatalf("hook should be restored: %v", err)
- }
- if string(data) != customContent {
- t.Errorf("restored hook content = %q, want %q", string(data), customContent)
- }
-
- // Backup should be gone
- backupPath := hookPath + backupSuffix
- if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
- t.Error("backup should be removed after restore")
- }
-}
-
-func TestRemoveGitHook_RestoresBackupWhenHookAlreadyGone(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create custom hook, install (creates backup), then delete the main hook
- customContent := "#!/bin/sh\necho 'original'\n"
- hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // Simulate another tool deleting our hook
- if err := os.Remove(hookPath); err != nil {
- t.Fatalf("failed to remove hook: %v", err)
- }
-
- _, err = RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
-
- // Backup should be restored even though the main hook was already gone
- data, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatal("backup should be restored to main hook path")
- }
- if string(data) != customContent {
- t.Errorf("restored hook content = %q, want %q", string(data), customContent)
- }
-
- // Backup file should be gone
- backupPath := hookPath + backupSuffix
- if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
- t.Error("backup file should not exist after restore")
- }
-}
-
-func TestGenerateChainedContent(t *testing.T) {
- t.Parallel()
-
- base := "#!/bin/sh\n# Trace CLI hooks\ntrace hooks git pre-push \"$1\" || true\n"
- result := generateChainedContent(base, "pre-push")
-
- // Should start with the base content
- if !strings.HasPrefix(result, base) {
- t.Error("chained content should start with base content")
- }
-
- // Should contain the chain comment
- if !strings.Contains(result, chainComment) {
- t.Error("chained content should contain chain comment")
- }
-
- // Should resolve hook directory from $0
- if !strings.Contains(result, `_trace_hook_dir="$(dirname "$0")"`) {
- t.Error("chained content should resolve hook directory from $0")
- }
-
- // Should check executable permission on backup
- expectedCheck := `[ -x "$_trace_hook_dir/pre-push` + backupSuffix + `" ]`
- if !strings.Contains(result, expectedCheck) {
- t.Errorf("chained content should check -x on backup, got:\n%s", result)
- }
-
- // Should forward all arguments with "$@"
- expectedExec := `"$_trace_hook_dir/pre-push` + backupSuffix + `" "$@"`
- if !strings.Contains(result, expectedExec) {
- t.Errorf("chained content should execute backup with $@, got:\n%s", result)
- }
-}
-
-func TestGenerateChainedContent_PostRewritePreservesStdinForBackup(t *testing.T) {
- t.Parallel()
-
- base := "#!/bin/sh\n# Trace CLI hooks\n# Post-rewrite hook: remap session linkage after amend/rebase rewrites\ntrace hooks git post-rewrite \"$1\" 2>/dev/null || true\n"
- result := generateChainedContent(base, "post-rewrite")
-
- if !strings.Contains(result, `_trace_stdin="$(mktemp "${TMPDIR:-/tmp}/trace-post-rewrite.XXXXXX")"`) {
- t.Fatalf("post-rewrite chained content should create temp stdin copy, got:\n%s", result)
- }
- if !strings.Contains(result, `cat > "$_trace_stdin"`) {
- t.Fatalf("post-rewrite chained content should capture stdin once, got:\n%s", result)
- }
- if !strings.Contains(result, `trace hooks git post-rewrite "$1" < "$_trace_stdin" 2>/dev/null || true`) {
- t.Fatalf("post-rewrite chained content should replay stdin into Trace handler, got:\n%s", result)
- }
- if !strings.Contains(result, `"$_trace_hook_dir/post-rewrite`+backupSuffix+`" "$@" < "$_trace_stdin"`) {
- t.Fatalf("post-rewrite chained content should replay stdin into backup hook, got:\n%s", result)
- }
-}
-
-func TestInstallGitHook_InstallRemoveReinstall(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // Create a custom hook
- customContent := "#!/bin/sh\necho 'user hook'\n"
- hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- if err := os.WriteFile(hookPath, []byte(customContent), 0o755); err != nil {
- t.Fatalf("failed to create custom hook: %v", err)
- }
-
- // Install: should back up and chain
- count, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("first install error: %v", err)
- }
- if count == 0 {
- t.Error("first install should install hooks")
- }
- backupPath := hookPath + backupSuffix
- if !fileExists(backupPath) {
- t.Fatal("backup should exist after install")
- }
-
- // Remove: should restore backup
- _, err = RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("remove error: %v", err)
- }
- data, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatal("hook should be restored after remove")
- }
- if string(data) != customContent {
- t.Errorf("restored hook = %q, want %q", string(data), customContent)
- }
- if fileExists(backupPath) {
- t.Error("backup should not exist after remove")
- }
-
- // Reinstall: should back up again and chain
- count, err = InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("reinstall error: %v", err)
- }
- if count == 0 {
- t.Error("reinstall should install hooks")
- }
- if !fileExists(backupPath) {
- t.Fatal("backup should exist after reinstall")
- }
- data, err = os.ReadFile(hookPath)
- if err != nil {
- t.Fatal("hook should exist after reinstall")
- }
- if !strings.Contains(string(data), traceHookMarker) {
- t.Error("reinstalled hook should contain Trace marker")
- }
- if !strings.Contains(string(data), chainComment) {
- t.Error("reinstalled hook should contain chain call")
- }
-}
-
-func TestRemoveGitHook_DoesNotOverwriteReplacedHook(t *testing.T) {
- _, hooksDir := initHooksTestRepo(t)
-
- // User has custom hook A
- hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
- hookAContent := "#!/bin/sh\necho 'hook A'\n"
- if err := os.WriteFile(hookPath, []byte(hookAContent), 0o755); err != nil {
- t.Fatalf("failed to create hook A: %v", err)
- }
-
- // trace enable: backs up A, installs our hook with chain
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // User replaces our hook with their own hook B
- hookBContent := "#!/bin/sh\necho 'hook B'\n"
- if err := os.WriteFile(hookPath, []byte(hookBContent), 0o755); err != nil {
- t.Fatalf("failed to create hook B: %v", err)
- }
-
- // trace disable: should NOT overwrite hook B with backup A
- _, err = RemoveGitHook(context.Background())
- if err != nil {
- t.Fatalf("RemoveGitHook(context.Background()) error = %v", err)
- }
-
- // Hook B should still be in place
- data, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatal("hook should still exist")
- }
- if string(data) != hookBContent {
- t.Errorf("hook content = %q, want hook B %q (should not be overwritten by backup)", string(data), hookBContent)
- }
-
- // Backup should still exist (not consumed)
- backupPath := hookPath + backupSuffix
- if !fileExists(backupPath) {
- t.Error("backup should be left in place when hook was modified")
- }
-}
-
-func TestRemoveGitHook_PermissionDenied(t *testing.T) {
- if os.Getuid() == 0 {
- t.Skip("Test cannot run as root (permission checks are bypassed)")
- }
-
- tmpDir, _ := initHooksTestRepo(t)
-
- // Install hooks first
- _, err := InstallGitHook(context.Background(), true, false, false)
- if err != nil {
- t.Fatalf("InstallGitHook() error = %v", err)
- }
-
- // Remove write permissions from hooks directory to cause permission error
- hooksDir := filepath.Join(tmpDir, ".git", "hooks")
- if err := os.Chmod(hooksDir, 0o555); err != nil {
- t.Fatalf("failed to change hooks dir permissions: %v", err)
- }
- // Restore permissions on cleanup
- t.Cleanup(func() {
- _ = os.Chmod(hooksDir, 0o755) //nolint:errcheck // Cleanup, best-effort
- })
-
- // Remove hooks should now fail with permission error
- removed, err := RemoveGitHook(context.Background())
- if err == nil {
- t.Fatal("RemoveGitHook(context.Background()) should return error when hooks cannot be deleted")
- }
- if removed != 0 {
- t.Errorf("RemoveGitHook(context.Background()) removed %d hooks, expected 0 when all fail", removed)
- }
- if !strings.Contains(err.Error(), "failed to remove hooks") {
- t.Errorf("error should mention 'failed to remove hooks', got: %v", err)
- }
-}
diff --git a/cli/strategy/manual_commit_2_test.go b/cli/strategy/manual_commit_2_test.go
new file mode 100644
index 0000000..f62fcac
--- /dev/null
+++ b/cli/strategy/manual_commit_2_test.go
@@ -0,0 +1,755 @@
+package strategy
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShadowStrategy_PrepareCommitMsg_SkipSources(t *testing.T) {
+ // Tests that merge, squash, and commit sources are skipped
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ commitMsgFile := filepath.Join(dir, "COMMIT_MSG")
+ originalMsg := "Merge branch 'feature'\n"
+
+ s := NewManualCommitStrategy()
+
+ skipSources := []string{"merge", "squash", "commit"}
+ for _, source := range skipSources {
+ t.Run(source, func(t *testing.T) {
+ if err := os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644); err != nil {
+ t.Fatalf("failed to write commit message file: %v", err)
+ }
+
+ prepErr := s.PrepareCommitMsg(context.Background(), commitMsgFile, source)
+ if prepErr != nil {
+ t.Errorf("PrepareCommitMsg() error = %v", prepErr)
+ }
+
+ // Message should be unchanged for these sources
+ content, readErr := os.ReadFile(commitMsgFile)
+ if readErr != nil {
+ t.Fatalf("failed to read commit message file: %v", readErr)
+ }
+ if string(content) != originalMsg {
+ t.Errorf("PrepareCommitMsg(source=%q) modified message: got %q, want %q",
+ source, content, originalMsg)
+ }
+ })
+ }
+}
+
+func TestShadowStrategy_PrepareCommitMsg_SkipsSessionWhenContentCheckFails(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+ t.Setenv("TRACE_TEST_TTY", "1")
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ err = s.InitializeSession(context.Background(), "test-session-corrupt-shadow", agent.AgentTypeClaudeCode, "", "", "")
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), "test-session-corrupt-shadow")
+ require.NoError(t, err)
+ require.NotNil(t, state)
+
+ shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ corruptRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), plumbing.ZeroHash)
+ require.NoError(t, repo.Storer.SetReference(corruptRef))
+
+ commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG")
+ originalMsg := "Test commit\n"
+ require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644))
+
+ err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "")
+ require.NoError(t, err)
+
+ content, err := os.ReadFile(commitMsgFile)
+ require.NoError(t, err)
+
+ _, found := trailers.ParseCheckpoint(string(content))
+ require.False(t, found, "corrupt session state should not add a dangling checkpoint trailer")
+ require.Equal(t, originalMsg, string(content))
+}
+
+func TestAddCheckpointTrailer_NoComment(t *testing.T) {
+ // Test that addCheckpointTrailer adds trailer without any comment lines
+ message := "Test commit message\n" //nolint:goconst // already present in codebase
+
+ result := addCheckpointTrailer(message, testTrailerCheckpointID)
+
+ // Should contain the trailer
+ if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
+ t.Errorf("addCheckpointTrailer() missing trailer, got: %q", result)
+ }
+
+ // Should NOT contain comment lines
+ if strings.Contains(result, "# Remove the Trace-Checkpoint") {
+ t.Errorf("addCheckpointTrailer() should not contain comment, got: %q", result)
+ }
+}
+
+func TestAddCheckpointTrailerWithComment_HasComment(t *testing.T) {
+ // Test that addCheckpointTrailerWithComment includes the explanatory comment
+ message := "Test commit message\n"
+
+ result := addCheckpointTrailerWithComment(message, testTrailerCheckpointID, "Claude Code", "add password hashing")
+
+ // Should contain the trailer
+ if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
+ t.Errorf("addCheckpointTrailerWithComment() missing trailer, got: %q", result)
+ }
+
+ // Should contain comment lines with agent name (before prompt)
+ if !strings.Contains(result, "# Remove the Trace-Checkpoint") {
+ t.Errorf("addCheckpointTrailerWithComment() should contain comment, got: %q", result)
+ }
+ if !strings.Contains(result, "Claude Code session context") {
+ t.Errorf("addCheckpointTrailerWithComment() should contain agent name in comment, got: %q", result)
+ }
+
+ // Should contain prompt line (after removal comment)
+ if !strings.Contains(result, "# Last Prompt: add password hashing") {
+ t.Errorf("addCheckpointTrailerWithComment() should contain prompt, got: %q", result)
+ }
+
+ // Verify order: Remove comment should come before Last Prompt
+ removeIdx := strings.Index(result, "# Remove the Trace-Checkpoint")
+ promptIdx := strings.Index(result, "# Last Prompt:")
+ if promptIdx < removeIdx {
+ t.Errorf("addCheckpointTrailerWithComment() prompt should come after remove comment, got: %q", result)
+ }
+}
+
+func TestAddCheckpointTrailerWithComment_NoPrompt(t *testing.T) {
+ // Test that addCheckpointTrailerWithComment works without a prompt
+ message := "Test commit message\n"
+
+ result := addCheckpointTrailerWithComment(message, testTrailerCheckpointID, "Claude Code", "")
+
+ // Should contain the trailer
+ if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
+ t.Errorf("addCheckpointTrailerWithComment() missing trailer, got: %q", result)
+ }
+
+ // Should NOT contain prompt line when prompt is empty
+ if strings.Contains(result, "# Last Prompt:") {
+ t.Errorf("addCheckpointTrailerWithComment() should not contain prompt line when empty, got: %q", result)
+ }
+
+ // Should still contain the removal comment
+ if !strings.Contains(result, "# Remove the Trace-Checkpoint") {
+ t.Errorf("addCheckpointTrailerWithComment() should contain comment, got: %q", result)
+ }
+}
+
+func TestAddCheckpointTrailer_ConventionalCommitSubject(t *testing.T) {
+ t.Parallel()
+
+ // Regression: single-line conventional commit subjects like "docs: Add foo"
+ // contain ": " which falsely triggered the "already has trailers" detection,
+ // causing the trailer to be appended without a blank line separator.
+ tests := []struct {
+ name string
+ message string
+ }{
+ {
+ name: "conventional commit docs",
+ message: "docs: Add red.md with information about the color red\n",
+ },
+ {
+ name: "conventional commit feat",
+ message: "feat: Add new login flow\n",
+ },
+ {
+ name: "conventional commit fix with scope",
+ message: "fix(auth): Resolve token expiry issue\n",
+ },
+ {
+ name: "single line no newline",
+ message: "docs: Add something",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result := addCheckpointTrailer(tt.message, testTrailerCheckpointID)
+
+ // The trailer must be separated from the subject by a blank line
+ if !strings.Contains(result, "\n\n"+trailers.CheckpointTrailerKey+":") {
+ t.Errorf("addCheckpointTrailer() trailer not separated by blank line from subject.\ngot: %q", result)
+ }
+ })
+ }
+}
+
+func TestAddCheckpointTrailer_ExistingTrailers(t *testing.T) {
+ t.Parallel()
+
+ // When a message already has trailers (in a separate paragraph), the
+ // new trailer should be appended directly (no extra blank line).
+ message := "feat: Add login\n\nSigned-off-by: Test User \n"
+ result := addCheckpointTrailer(message, testTrailerCheckpointID)
+
+ // Should NOT add a double blank line before our trailer
+ if strings.Contains(result, "\n\n"+trailers.CheckpointTrailerKey) {
+ t.Errorf("addCheckpointTrailer() added extra blank line before existing trailer block.\ngot: %q", result)
+ }
+
+ // Should contain both trailers
+ if !strings.Contains(result, "Signed-off-by:") {
+ t.Errorf("addCheckpointTrailer() lost existing trailer.\ngot: %q", result)
+ }
+ if !strings.Contains(result, trailers.CheckpointTrailerKey+":") {
+ t.Errorf("addCheckpointTrailer() missing our trailer.\ngot: %q", result)
+ }
+}
+
+func TestShadowStrategy_GetCheckpointLog_WithCheckpointID(t *testing.T) {
+ // This test verifies that GetCheckpointLog correctly uses the checkpoint ID
+ // to look up the log. Since getCheckpointLog requires a full git setup
+ // with trace/checkpoints/v1 branch, we test the lookup logic by checking error behavior.
+
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := NewManualCommitStrategy()
+
+ // Checkpoint with checkpoint ID (12 hex chars)
+ checkpoint := Checkpoint{
+ CheckpointID: "a1b2c3d4e5f6",
+ Message: "Checkpoint: a1b2c3d4e5f6",
+ Timestamp: time.Now(),
+ }
+
+ // This should attempt to call getCheckpointLog (which will fail because
+ // there's no trace/checkpoints/v1 branch), but the important thing is it uses
+ // the checkpoint ID to look up metadata
+ _, err = s.GetCheckpointLog(context.Background(), checkpoint)
+ if err == nil {
+ t.Error("GetCheckpointLog() expected error (no sessions branch), got nil")
+ }
+ // The error should be about sessions branch, not about parsing
+ if err != nil && err.Error() != "sessions branch not found" {
+ t.Logf("GetCheckpointLog() error = %v (expected sessions branch error)", err)
+ }
+}
+
+func TestShadowStrategy_GetCheckpointLog_NoCheckpointID(t *testing.T) {
+ // Test that checkpoints without checkpoint ID return ErrNoMetadata
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := NewManualCommitStrategy()
+
+ // Checkpoint without checkpoint ID
+ checkpoint := Checkpoint{
+ CheckpointID: "",
+ Message: "Some other message",
+ Timestamp: time.Now(),
+ }
+
+ // This should return ErrNoMetadata since there's no checkpoint ID
+ _, err = s.GetCheckpointLog(context.Background(), checkpoint)
+ if err == nil {
+ t.Error("GetCheckpointLog() expected error for missing checkpoint ID, got nil")
+ }
+ if !errors.Is(err, ErrNoMetadata) {
+ t.Errorf("GetCheckpointLog() expected ErrNoMetadata, got %v", err)
+ }
+}
+
+func TestShadowStrategy_FilesTouched_OnlyModifiedFiles(t *testing.T) {
+ // This test verifies that files_touched only contains files that were actually
+ // modified during the session, not ALL files in the repository.
+ //
+ // The fix tracks files in SessionState.FilesTouched as they are modified,
+ // rather than collecting all files from the shadow branch tree.
+
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create initial commit with multiple pre-existing files
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create 3 pre-existing files that should NOT be in files_touched
+ preExistingFiles := []string{"existing1.txt", "existing2.txt", "existing3.txt"}
+ for _, f := range preExistingFiles {
+ filePath := filepath.Join(dir, f)
+ if err := os.WriteFile(filePath, []byte("original content of "+f), 0o644); err != nil {
+ t.Fatalf("failed to write file %s: %v", f, err)
+ }
+ if _, err := worktree.Add(f); err != nil {
+ t.Fatalf("failed to add file %s: %v", f, err)
+ }
+ }
+
+ _, err = worktree.Commit("Initial commit with pre-existing files", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-session-123"
+
+ // Create metadata directory with a transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ // Write transcript file (minimal valid JSONL)
+ transcript := `{"type":"human","message":{"content":"modify existing1.txt"}}
+{"type":"assistant","message":{"content":"I'll modify existing1.txt for you."}}
+`
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // First checkpoint using SaveStep - captures ALL working directory files
+ // (for rewind purposes), but tracks only modified files in FilesTouched
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{}, // No files modified yet
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Now simulate a second checkpoint where ONLY existing1.txt is modified
+ // (but NOT existing2.txt or existing3.txt)
+ modifiedContent := []byte("MODIFIED content of existing1.txt")
+ if err := os.WriteFile(filepath.Join(dir, "existing1.txt"), modifiedContent, 0o644); err != nil {
+ t.Fatalf("failed to modify existing1.txt: %v", err)
+ }
+
+ // Second checkpoint using SaveStep - only modified file should be tracked
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"existing1.txt"}, // Only this file was modified
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Load session state to verify FilesTouched
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+
+ // Now condense the session
+ checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Verify that files_touched only contains the file that was actually modified
+ expectedFilesTouched := []string{"existing1.txt"}
+
+ // Check what we actually got
+ if len(result.FilesTouched) != len(expectedFilesTouched) {
+ t.Errorf("FilesTouched contains %d files, want %d.\nGot: %v\nWant: %v",
+ len(result.FilesTouched), len(expectedFilesTouched),
+ result.FilesTouched, expectedFilesTouched)
+ }
+
+ // Verify the exact content
+ filesTouchedMap := make(map[string]bool)
+ for _, f := range result.FilesTouched {
+ filesTouchedMap[f] = true
+ }
+
+ // Check that ONLY the modified file is in files_touched
+ for _, expected := range expectedFilesTouched {
+ if !filesTouchedMap[expected] {
+ t.Errorf("Expected file %q to be in files_touched, but it was not. Got: %v", expected, result.FilesTouched)
+ }
+ }
+
+ // Check that pre-existing unmodified files are NOT in files_touched
+ unmodifiedFiles := []string{"existing2.txt", "existing3.txt"}
+ for _, unmodified := range unmodifiedFiles {
+ if filesTouchedMap[unmodified] {
+ t.Errorf("File %q should NOT be in files_touched (it was not modified during the session), but it was included. Got: %v",
+ unmodified, result.FilesTouched)
+ }
+ }
+}
+
+// TestDeleteShadowBranch verifies that deleteShadowBranch correctly deletes a shadow branch.
+func TestDeleteShadowBranch(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Create a dummy commit to use as branch target
+ emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
+ dummyCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com")
+ if err != nil {
+ t.Fatalf("failed to create dummy commit: %v", err)
+ }
+
+ // Create a shadow branch
+ shadowBranchName := "trace/abc1234"
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ ref := plumbing.NewHashReference(refName, dummyCommitHash)
+ if err := repo.Storer.SetReference(ref); err != nil {
+ t.Fatalf("failed to create shadow branch: %v", err)
+ }
+
+ // Verify branch exists
+ _, err = repo.Reference(refName, true)
+ if err != nil {
+ t.Fatalf("shadow branch should exist: %v", err)
+ }
+
+ // Delete the shadow branch
+ err = deleteShadowBranch(context.Background(), repo, shadowBranchName)
+ if err != nil {
+ t.Fatalf("deleteShadowBranch() error = %v", err)
+ }
+
+ // Verify branch is deleted
+ _, err = repo.Reference(refName, true)
+ if err == nil {
+ t.Error("shadow branch should be deleted, but still exists")
+ }
+}
+
+// TestDeleteShadowBranch_NonExistent verifies that deleting a non-existent branch is idempotent.
+func TestDeleteShadowBranch_NonExistent(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Try to delete a branch that doesn't exist - should not error
+ err = deleteShadowBranch(context.Background(), repo, "trace/nonexistent")
+ if err != nil {
+ t.Errorf("deleteShadowBranch() for non-existent branch should not error, got: %v", err)
+ }
+}
+
+// TestSessionState_LastCheckpointID verifies that LastCheckpointID is persisted correctly.
+func TestSessionState_LastCheckpointID(t *testing.T) {
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ // Create session state with LastCheckpointID
+ state := &SessionState{
+ SessionID: "test-session-123",
+ BaseCommit: "abc123def456",
+ StartedAt: time.Now(),
+ StepCount: 5,
+ LastCheckpointID: "a1b2c3d4e5f6",
+ }
+
+ // Save state
+ err = s.saveSessionState(context.Background(), state)
+ if err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ // Load state and verify LastCheckpointID
+ loaded, err := s.loadSessionState(context.Background(), "test-session-123")
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ require.NotNil(t, loaded, "loadSessionState() returned nil")
+
+ if loaded.LastCheckpointID != state.LastCheckpointID {
+ t.Errorf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, state.LastCheckpointID)
+ }
+}
+
+// TestSessionState_TokenUsagePersistence verifies that token usage fields are persisted correctly
+// across session state save/load cycles. This is critical for tracking token usage in the
+// manual-commit strategy where session state is persisted to disk between checkpoints.
+func TestSessionState_TokenUsagePersistence(t *testing.T) {
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ // Create session state with token usage fields
+ state := &SessionState{
+ SessionID: "test-session-token-usage",
+ BaseCommit: "abc123def456",
+ StartedAt: time.Now(),
+ StepCount: 5,
+ CheckpointTranscriptStart: 42,
+ TranscriptIdentifierAtStart: "test-uuid-abc123",
+ TokenUsage: &agent.TokenUsage{
+ InputTokens: 1000,
+ CacheCreationTokens: 200,
+ CacheReadTokens: 300,
+ OutputTokens: 500,
+ APICallCount: 5,
+ },
+ }
+
+ // Save state
+ err = s.saveSessionState(context.Background(), state)
+ if err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ // Load state and verify token usage fields are persisted
+ loaded, err := s.loadSessionState(context.Background(), "test-session-token-usage")
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ require.NotNil(t, loaded, "loadSessionState() returned nil")
+
+ // Verify CheckpointTranscriptStart
+ if loaded.CheckpointTranscriptStart != state.CheckpointTranscriptStart {
+ t.Errorf("CheckpointTranscriptStart = %d, want %d", loaded.CheckpointTranscriptStart, state.CheckpointTranscriptStart)
+ }
+
+ // Verify TranscriptIdentifierAtStart
+ if loaded.TranscriptIdentifierAtStart != state.TranscriptIdentifierAtStart {
+ t.Errorf("TranscriptIdentifierAtStart = %q, want %q", loaded.TranscriptIdentifierAtStart, state.TranscriptIdentifierAtStart)
+ }
+
+ // Verify TokenUsage
+ if loaded.TokenUsage == nil {
+ t.Fatal("TokenUsage should be persisted, got nil")
+ }
+ if loaded.TokenUsage.InputTokens != state.TokenUsage.InputTokens {
+ t.Errorf("TokenUsage.InputTokens = %d, want %d", loaded.TokenUsage.InputTokens, state.TokenUsage.InputTokens)
+ }
+ if loaded.TokenUsage.CacheCreationTokens != state.TokenUsage.CacheCreationTokens {
+ t.Errorf("TokenUsage.CacheCreationTokens = %d, want %d", loaded.TokenUsage.CacheCreationTokens, state.TokenUsage.CacheCreationTokens)
+ }
+ if loaded.TokenUsage.CacheReadTokens != state.TokenUsage.CacheReadTokens {
+ t.Errorf("TokenUsage.CacheReadTokens = %d, want %d", loaded.TokenUsage.CacheReadTokens, state.TokenUsage.CacheReadTokens)
+ }
+ if loaded.TokenUsage.OutputTokens != state.TokenUsage.OutputTokens {
+ t.Errorf("TokenUsage.OutputTokens = %d, want %d", loaded.TokenUsage.OutputTokens, state.TokenUsage.OutputTokens)
+ }
+ if loaded.TokenUsage.APICallCount != state.TokenUsage.APICallCount {
+ t.Errorf("TokenUsage.APICallCount = %d, want %d", loaded.TokenUsage.APICallCount, state.TokenUsage.APICallCount)
+ }
+}
+
+// TestShadowStrategy_PrepareCommitMsg_ReusesLastCheckpointID verifies that PrepareCommitMsg
+// reuses the LastCheckpointID when there's no new content to condense.
+func TestShadowStrategy_PrepareCommitMsg_ReusesLastCheckpointID(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create initial commit
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+ if _, err := worktree.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ // Create session state with LastCheckpointID but no new content
+ // (simulating state after first commit with condensation)
+ state := &SessionState{
+ SessionID: "test-session",
+ BaseCommit: initialCommit.String(),
+ WorktreePath: dir,
+ StartedAt: time.Now(),
+ StepCount: 1,
+ CheckpointTranscriptStart: 10, // Already condensed
+ LastCheckpointID: testTrailerCheckpointID,
+ }
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ // Note: We can't fully test PrepareCommitMsg without setting up a shadow branch
+ // with transcript, but we can verify the session state has LastCheckpointID set
+ // The actual behavior is tested through integration tests
+
+ // Verify the state was saved correctly
+ loaded, err := s.loadSessionState(context.Background(), "test-session")
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if loaded.LastCheckpointID != testTrailerCheckpointID {
+ t.Errorf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
+ }
+}
+
+func TestParsePostRewritePairs(t *testing.T) {
+ pairs, err := parsePostRewritePairs(strings.NewReader("oldsha newsha\n\nold2 new2\n"))
+ if err != nil {
+ t.Fatalf("parsePostRewritePairs() error = %v", err)
+ }
+ if len(pairs) != 2 {
+ t.Fatalf("len(pairs) = %d, want 2", len(pairs))
+ }
+ if pairs[0].OldSHA != "oldsha" || pairs[0].NewSHA != "newsha" {
+ t.Fatalf("pairs[0] = %+v, want oldsha->newsha", pairs[0])
+ }
+ if pairs[1].OldSHA != "old2" || pairs[1].NewSHA != "new2" {
+ t.Fatalf("pairs[1] = %+v, want old2->new2", pairs[1])
+ }
+}
+
+func TestParsePostRewritePairs_AllowsOptionalExtraField(t *testing.T) {
+ pairs, err := parsePostRewritePairs(strings.NewReader("oldsha newsha extra-info\n"))
+ if err != nil {
+ t.Fatalf("parsePostRewritePairs() error = %v", err)
+ }
+ if len(pairs) != 1 {
+ t.Fatalf("len(pairs) = %d, want 1", len(pairs))
+ }
+ if pairs[0].OldSHA != "oldsha" || pairs[0].NewSHA != "newsha" {
+ t.Fatalf("pairs[0] = %+v, want oldsha->newsha", pairs[0])
+ }
+}
+
+func TestParsePostRewritePairs_InvalidLine(t *testing.T) {
+ _, err := parsePostRewritePairs(strings.NewReader("missing-second-column\n"))
+ if err == nil {
+ t.Fatal("parsePostRewritePairs() error = nil, want error")
+ }
+}
+
+func TestShadowStrategy_PostRewrite_RemapsMatchingSessionInWorktree(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ t.Chdir(dir)
+ oldSHA := strings.Repeat("a", 40)
+ newSHA := strings.Repeat("b", 40)
+ worktreePath, err := paths.WorktreeRoot(context.Background())
+ if err != nil {
+ t.Fatalf("WorktreeRoot() error = %v", err)
+ }
+
+ s := &ManualCommitStrategy{}
+ state := &SessionState{
+ SessionID: "session-1",
+ BaseCommit: oldSHA,
+ AttributionBaseCommit: oldSHA,
+ WorktreePath: worktreePath,
+ StartedAt: time.Now(),
+ LastCheckpointID: testTrailerCheckpointID,
+ }
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldSHA+" "+newSHA+"\n")); err != nil {
+ t.Fatalf("PostRewrite() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), state.SessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if loaded.BaseCommit != newSHA {
+ t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newSHA)
+ }
+ if loaded.AttributionBaseCommit != newSHA {
+ t.Fatalf("AttributionBaseCommit = %q, want %q", loaded.AttributionBaseCommit, newSHA)
+ }
+ if loaded.LastCheckpointID != testTrailerCheckpointID {
+ t.Fatalf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
+ }
+}
diff --git a/cli/strategy/manual_commit_3_test.go b/cli/strategy/manual_commit_3_test.go
new file mode 100644
index 0000000..77fc217
--- /dev/null
+++ b/cli/strategy/manual_commit_3_test.go
@@ -0,0 +1,707 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func TestShadowStrategy_PostRewrite_MigratesExistingShadowBranch(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "tracked.txt", "one\n")
+ testutil.GitAdd(t, dir, "tracked.txt")
+ testutil.GitCommit(t, dir, "initial")
+ t.Chdir(dir)
+
+ repo, err := OpenRepository(context.Background())
+ if err != nil {
+ t.Fatalf("OpenRepository() error = %v", err)
+ }
+ head, err := repo.Head()
+ if err != nil {
+ t.Fatalf("Head() error = %v", err)
+ }
+ oldBaseCommit := head.Hash().String()
+
+ testutil.WriteFile(t, dir, "tracked.txt", "two\n")
+ testutil.GitAdd(t, dir, "tracked.txt")
+ testutil.GitCommit(t, dir, "second")
+ head, err = repo.Head()
+ if err != nil {
+ t.Fatalf("Head() after second commit error = %v", err)
+ }
+ newBaseCommit := head.Hash().String()
+
+ worktreePath, err := paths.WorktreeRoot(context.Background())
+ if err != nil {
+ t.Fatalf("WorktreeRoot() error = %v", err)
+ }
+ worktreeID, err := paths.GetWorktreeID(worktreePath)
+ if err != nil {
+ t.Fatalf("GetWorktreeID() error = %v", err)
+ }
+
+ oldShadowBranch := checkpoint.ShadowBranchNameForCommit(oldBaseCommit, worktreeID)
+ newShadowBranch := checkpoint.ShadowBranchNameForCommit(newBaseCommit, worktreeID)
+ oldShadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(oldShadowBranch), plumbing.NewHash(oldBaseCommit))
+ if err := repo.Storer.SetReference(oldShadowRef); err != nil {
+ t.Fatalf("SetReference(old shadow) error = %v", err)
+ }
+
+ s := &ManualCommitStrategy{}
+ state := &SessionState{
+ SessionID: "session-1",
+ BaseCommit: oldBaseCommit,
+ AttributionBaseCommit: oldBaseCommit,
+ WorktreePath: worktreePath,
+ WorktreeID: worktreeID,
+ StartedAt: time.Now(),
+ LastCheckpointID: testTrailerCheckpointID,
+ }
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldBaseCommit+" "+newBaseCommit+" extra\n")); err != nil {
+ t.Fatalf("PostRewrite() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), state.SessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if loaded.BaseCommit != newBaseCommit {
+ t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newBaseCommit)
+ }
+ if loaded.AttributionBaseCommit != oldBaseCommit {
+ t.Fatalf("AttributionBaseCommit = %q, want original %q when shadow branch migrates", loaded.AttributionBaseCommit, oldBaseCommit)
+ }
+ if !referenceExists(t, repo, plumbing.NewBranchReferenceName(newShadowBranch)) {
+ t.Fatalf("expected migrated shadow branch %q to exist", newShadowBranch)
+ }
+ if referenceExists(t, repo, plumbing.NewBranchReferenceName(oldShadowBranch)) {
+ t.Fatalf("expected old shadow branch %q to be removed", oldShadowBranch)
+ }
+}
+
+func TestShadowStrategy_MigrateAndPersistIfNeeded_PersistsBaseCommitWithoutShadowBranch(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "tracked.txt", "one\n")
+ testutil.GitAdd(t, dir, "tracked.txt")
+ testutil.GitCommit(t, dir, "initial")
+ t.Chdir(dir)
+
+ repo, err := OpenRepository(context.Background())
+ if err != nil {
+ t.Fatalf("OpenRepository() error = %v", err)
+ }
+ head, err := repo.Head()
+ if err != nil {
+ t.Fatalf("Head() error = %v", err)
+ }
+ oldBaseCommit := head.Hash().String()
+
+ testutil.WriteFile(t, dir, "tracked.txt", "two\n")
+ testutil.GitAdd(t, dir, "tracked.txt")
+ testutil.GitCommit(t, dir, "second")
+ head, err = repo.Head()
+ if err != nil {
+ t.Fatalf("Head() after second commit error = %v", err)
+ }
+ newBaseCommit := head.Hash().String()
+
+ worktreePath, err := paths.WorktreeRoot(context.Background())
+ if err != nil {
+ t.Fatalf("WorktreeRoot() error = %v", err)
+ }
+
+ s := &ManualCommitStrategy{}
+ state := &SessionState{
+ SessionID: "session-1",
+ BaseCommit: oldBaseCommit,
+ AttributionBaseCommit: oldBaseCommit,
+ WorktreePath: worktreePath,
+ StartedAt: time.Now(),
+ LastCheckpointID: testTrailerCheckpointID,
+ }
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ if err := s.migrateAndPersistIfNeeded(context.Background(), repo, state); err != nil {
+ t.Fatalf("migrateAndPersistIfNeeded() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), state.SessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if loaded.BaseCommit != newBaseCommit {
+ t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newBaseCommit)
+ }
+}
+
+func TestShadowStrategy_PostRewrite_DoesNotTouchOtherWorktrees(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ t.Chdir(dir)
+ oldSHA := strings.Repeat("a", 40)
+ newSHA := strings.Repeat("b", 40)
+
+ s := &ManualCommitStrategy{}
+ other := &SessionState{
+ SessionID: "other-worktree",
+ BaseCommit: oldSHA,
+ AttributionBaseCommit: oldSHA,
+ WorktreePath: filepath.Join(dir, "other"),
+ StartedAt: time.Now(),
+ LastCheckpointID: testTrailerCheckpointID,
+ }
+ if err := s.saveSessionState(context.Background(), other); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldSHA+" "+newSHA+"\n")); err != nil {
+ t.Fatalf("PostRewrite() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), other.SessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if loaded.BaseCommit != oldSHA {
+ t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, oldSHA)
+ }
+ if loaded.AttributionBaseCommit != oldSHA {
+ t.Fatalf("AttributionBaseCommit = %q, want %q", loaded.AttributionBaseCommit, oldSHA)
+ }
+ if loaded.LastCheckpointID != testTrailerCheckpointID {
+ t.Fatalf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
+ }
+}
+
+func referenceExists(t *testing.T, repo *git.Repository, refName plumbing.ReferenceName) bool {
+ t.Helper()
+
+ _, err := repo.Reference(refName, true)
+ return err == nil
+}
+
+// TestShadowStrategy_CondenseSession_EphemeralBranchTrailer verifies that checkpoint commits
+// on the trace/checkpoints/v1 branch include the Ephemeral-branch trailer indicating which shadow
+// branch the checkpoint originated from.
+func TestShadowStrategy_CondenseSession_EphemeralBranchTrailer(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ // Create initial commit with a file
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ initialFile := filepath.Join(dir, "initial.txt")
+ if err := os.WriteFile(initialFile, []byte("initial content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("initial.txt"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-session-ephemeral"
+
+ // Create metadata directory with transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(testTranscriptPromptResponse), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Use SaveStep to create a checkpoint (this creates the shadow branch)
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Load session state
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+
+ // Condense the session
+ checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
+ _, err = s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Get the sessions branch commit and verify the Ephemeral-branch trailer
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch reference: %v", err)
+ }
+
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+
+ // Verify the commit message contains the Ephemeral-branch trailer
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ expectedTrailer := "Ephemeral-branch: " + shadowBranchName
+ if !strings.Contains(sessionsCommit.Message, expectedTrailer) {
+ t.Errorf("sessions branch commit should contain %q trailer, got message:\n%s", expectedTrailer, sessionsCommit.Message)
+ }
+}
+
+// TestSaveStep_EmptyBaseCommit_Recovery verifies that SaveStep recovers gracefully
+// when a session state exists with empty BaseCommit (can happen from concurrent warning state).
+func TestSaveStep_EmptyBaseCommit_Recovery(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+
+ // Create initial commit
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-empty-basecommit-test"
+
+ // Create a partial session state with empty BaseCommit
+ // (simulates a partial session state with empty BaseCommit)
+ partialState := &SessionState{
+ SessionID: sessionID,
+ BaseCommit: "", // Empty! This is the bug scenario
+ StartedAt: time.Now(),
+ }
+ if err := s.saveSessionState(context.Background(), partialState); err != nil {
+ t.Fatalf("failed to save partial state: %v", err)
+ }
+
+ // Create metadata directory
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // SaveStep should recover by re-initializing the session state
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Test checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() should recover from empty BaseCommit, got error: %v", err)
+ }
+
+ // Verify session state now has a valid BaseCommit
+ loaded, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("failed to load session state: %v", err)
+ }
+ if loaded.BaseCommit == "" {
+ t.Error("BaseCommit should be populated after recovery")
+ }
+ if loaded.StepCount != 1 {
+ t.Errorf("StepCount = %d, want 1", loaded.StepCount)
+ }
+}
+
+// TestSaveStep_UsesCtxAgentType_WhenNoSessionState tests that SaveStep uses
+// ctx.AgentType when no session state exists.
+func TestSaveStep_UsesCtxAgentType_WhenNoSessionState(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2026-02-06-agent-type-test"
+
+ // NO session state exists (simulates InitializeSession failure)
+ // SaveStep should use ctx.AgentType
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Test checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ AgentType: agent.AgentTypeClaudeCode,
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("failed to load session state: %v", err)
+ }
+ if loaded.AgentType != agent.AgentTypeClaudeCode {
+ t.Errorf("AgentType = %q, want %q", loaded.AgentType, agent.AgentTypeClaudeCode)
+ }
+}
+
+// TestSaveStep_UsesCtxAgentType_WhenPartialState tests that SaveStep uses
+// ctx.AgentType when a partial session state exists (empty BaseCommit and AgentType).
+func TestSaveStep_UsesCtxAgentType_WhenPartialState(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init git repo: %v", err)
+ }
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("test.txt"); err != nil {
+ t.Fatalf("failed to add file: %v", err)
+ }
+ if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2026-02-06-partial-state-agent-test"
+
+ // Create partial session state with empty BaseCommit and no AgentType
+ partialState := &SessionState{
+ SessionID: sessionID,
+ BaseCommit: "",
+ StartedAt: time.Now(),
+ }
+ if err := s.saveSessionState(context.Background(), partialState); err != nil {
+ t.Fatalf("failed to save partial state: %v", err)
+ }
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+ transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Test checkpoint",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ AgentType: agent.AgentTypeClaudeCode,
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ loaded, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("failed to load session state: %v", err)
+ }
+ if loaded.AgentType != agent.AgentTypeClaudeCode {
+ t.Errorf("AgentType = %q, want %q", loaded.AgentType, agent.AgentTypeClaudeCode)
+ }
+}
+
+// TestCountTranscriptItems tests counting lines/messages in different transcript formats.
+func TestCountTranscriptItems(t *testing.T) {
+ tests := []struct {
+ name string
+ agentType types.AgentType
+ content string
+ expected int
+ }{
+ {
+ name: "Gemini JSON with messages",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "user", "content": "Hello"},
+ {"type": "gemini", "content": "Hi there!"}
+ ]
+ }`,
+ expected: 2,
+ },
+ {
+ name: "Gemini empty messages array",
+ agentType: agent.AgentTypeGemini,
+ content: `{"messages": []}`,
+ expected: 0,
+ },
+ {
+ name: "Claude Code JSONL",
+ agentType: agent.AgentTypeClaudeCode,
+ content: `{"type":"human","message":{"content":"Hello"}}
+{"type":"assistant","message":{"content":"Hi"}}`,
+ expected: 2,
+ },
+ {
+ name: "Claude Code JSONL with trailing newline",
+ agentType: agent.AgentTypeClaudeCode,
+ content: `{"type":"human","message":{"content":"Hello"}}
+{"type":"assistant","message":{"content":"Hi"}}
+`,
+ expected: 2,
+ },
+ {
+ name: "empty string",
+ agentType: agent.AgentTypeClaudeCode,
+ content: "",
+ expected: 0,
+ },
+ {
+ name: "Gemini JSON with array content (real format)",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "user", "content": [{"text": "Hello"}]},
+ {"type": "gemini", "content": "Hi there!"},
+ {"type": "user", "content": [{"text": "Do something"}]},
+ {"type": "gemini", "content": "Done!"}
+ ]
+ }`,
+ expected: 4,
+ },
+ {
+ name: "OpenCode export JSON with messages",
+ agentType: agent.AgentTypeOpenCode,
+ content: `{
+ "info": {"id": "session-1"},
+ "messages": [
+ {"info": {"role": "user"}, "parts": [{"type": "text", "text": "Hello"}]},
+ {"info": {"role": "assistant"}, "parts": [{"type": "text", "text": "Hi there!"}]}
+ ]
+ }`,
+ expected: 2,
+ },
+ {
+ name: "OpenCode export JSON empty messages",
+ agentType: agent.AgentTypeOpenCode,
+ content: `{"info": {"id": "session-1"}, "messages": []}`,
+ expected: 0,
+ },
+ {
+ name: "OpenCode invalid JSON",
+ agentType: agent.AgentTypeOpenCode,
+ content: `not valid json`,
+ expected: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := countTranscriptItems(tt.agentType, tt.content)
+ if result != tt.expected {
+ t.Errorf("countTranscriptItems() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+// TestExtractUserPrompts tests extraction of user prompts from different transcript formats.
+func TestExtractUserPrompts(t *testing.T) {
+ tests := []struct {
+ name string
+ agentType types.AgentType
+ content string
+ expected []string
+ }{
+ {
+ name: "Gemini single user prompt",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "user", "content": "Create a file called test.txt"}
+ ]
+ }`,
+ expected: []string{"Create a file called test.txt"},
+ },
+ {
+ name: "Gemini multiple user prompts",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "user", "content": "First prompt"},
+ {"type": "gemini", "content": "Response 1"},
+ {"type": "user", "content": "Second prompt"},
+ {"type": "gemini", "content": "Response 2"}
+ ]
+ }`,
+ expected: []string{"First prompt", "Second prompt"},
+ },
+ {
+ name: "Gemini no user messages",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "gemini", "content": "Hello!"}
+ ]
+ }`,
+ expected: nil,
+ },
+ {
+ name: "Claude Code JSONL with user messages",
+ agentType: agent.AgentTypeClaudeCode,
+ content: `{"type":"user","message":{"content":"Hello"}}
+{"type":"assistant","message":{"content":"Hi"}}
+{"type":"user","message":{"content":"Goodbye"}}`,
+ expected: []string{"Hello", "Goodbye"},
+ },
+ {
+ name: "empty string",
+ agentType: agent.AgentTypeClaudeCode,
+ content: "",
+ expected: nil,
+ },
+ {
+ name: "Gemini array content (real format)",
+ agentType: agent.AgentTypeGemini,
+ content: `{
+ "messages": [
+ {"type": "user", "content": [{"text": "Create a file"}]},
+ {"type": "gemini", "content": "Done!"},
+ {"type": "user", "content": [{"text": "Edit the file"}]},
+ {"type": "gemini", "content": "Updated!"}
+ ]
+ }`,
+ expected: []string{"Create a file", "Edit the file"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := extractUserPrompts(tt.agentType, tt.content)
+ if len(result) != len(tt.expected) {
+ t.Errorf("extractUserPrompts() returned %d prompts, want %d", len(result), len(tt.expected))
+ return
+ }
+ for i, prompt := range result {
+ if prompt != tt.expected[i] {
+ t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i])
+ }
+ }
+ })
+ }
+}
diff --git a/cli/strategy/manual_commit_4_test.go b/cli/strategy/manual_commit_4_test.go
new file mode 100644
index 0000000..44307d8
--- /dev/null
+++ b/cli/strategy/manual_commit_4_test.go
@@ -0,0 +1,593 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestCondenseSession_IncludesInitialAttribution verifies that when manual-commit
+// condenses a session, it calculates InitialAttribution by comparing the shadow branch
+// (agent work) to HEAD (what was committed).
+func TestCondenseSession_IncludesInitialAttribution(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ // Create initial commit with a file
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create a file with some content
+ testFile := filepath.Join(dir, "test.go")
+ originalContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"
+ if err := os.WriteFile(testFile, []byte(originalContent), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("test.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-attribution"
+
+ // Create metadata directory with transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ transcript := `{"type":"human","message":{"content":"modify test.go"}}
+{"type":"assistant","message":{"content":"I'll modify test.go"}}
+`
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Agent modifies the file (adds a new function)
+ agentContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n\nfunc newFunc() {\n\tprintln(\"agent added this\")\n}\n"
+ if err := os.WriteFile(testFile, []byte(agentContent), 0o644); err != nil {
+ t.Fatalf("failed to write agent changes: %v", err)
+ }
+
+ // First checkpoint - captures agent's work on shadow branch
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.go"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Human edits the file (adds a comment)
+ humanEditedContent := "package main\n\n// Human added this comment\nfunc main() {\n\tprintln(\"hello\")\n}\n\nfunc newFunc() {\n\tprintln(\"agent added this\")\n}\n"
+ if err := os.WriteFile(testFile, []byte(humanEditedContent), 0o644); err != nil {
+ t.Fatalf("failed to write human edits: %v", err)
+ }
+
+ // Stage and commit the human-edited file (this is what the user does)
+ if _, err := worktree.Add("test.go"); err != nil {
+ t.Fatalf("failed to stage human edits: %v", err)
+ }
+ _, err = worktree.Commit("Add new function with human comment", &git.CommitOptions{
+ Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit human edits: %v", err)
+ }
+
+ // Load session state
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+
+ // Condense the session - this should calculate InitialAttribution
+ checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Verify CondenseResult
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
+ }
+
+ // Read metadata from trace/checkpoints/v1 branch and verify InitialAttribution
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // InitialAttribution is stored in session-level metadata (0/metadata.json), not root (0-based indexing)
+ sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(sessionMetadataPath)
+ if err != nil {
+ t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
+ }
+
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata.json: %v", err)
+ }
+
+ // Parse and verify InitialAttribution is present
+ var metadata struct {
+ InitialAttribution *struct {
+ AgentLines int `json:"agent_lines"`
+ HumanAdded int `json:"human_added"`
+ HumanModified int `json:"human_modified"`
+ HumanRemoved int `json:"human_removed"`
+ TotalCommitted int `json:"total_committed"`
+ AgentPercentage float64 `json:"agent_percentage"`
+ } `json:"initial_attribution"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata.json: %v", err)
+ }
+
+ if metadata.InitialAttribution == nil {
+ t.Fatal("InitialAttribution should be present in session metadata.json for manual-commit")
+ }
+
+ // Verify the attribution values are reasonable
+ // Agent added new function, human added a comment line
+ // The exact line counts depend on how the diff algorithm interprets the changes
+ // (insertion vs modification), but we should have non-zero totals and reasonable percentages.
+ if metadata.InitialAttribution.TotalCommitted == 0 {
+ t.Error("TotalCommitted should be > 0")
+ }
+ if metadata.InitialAttribution.AgentLines == 0 {
+ t.Error("AgentLines should be > 0 (agent wrote code)")
+ }
+
+ // Human contribution should be captured in either HumanAdded or HumanModified
+ // When inserting lines in the middle of existing code, the diff algorithm may
+ // interpret it as a modification rather than a pure addition.
+ humanContribution := metadata.InitialAttribution.HumanAdded + metadata.InitialAttribution.HumanModified
+ if humanContribution == 0 {
+ t.Error("Human contribution (HumanAdded + HumanModified) should be > 0")
+ }
+
+ if metadata.InitialAttribution.AgentPercentage <= 0 || metadata.InitialAttribution.AgentPercentage > 100 {
+ t.Errorf("AgentPercentage should be between 0-100, got %f", metadata.InitialAttribution.AgentPercentage)
+ }
+
+ t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, human_removed=%d, total=%d, percentage=%.1f%%",
+ metadata.InitialAttribution.AgentLines,
+ metadata.InitialAttribution.HumanAdded,
+ metadata.InitialAttribution.HumanModified,
+ metadata.InitialAttribution.HumanRemoved,
+ metadata.InitialAttribution.TotalCommitted,
+ metadata.InitialAttribution.AgentPercentage)
+}
+
+// TestCondenseSession_AttributionWithoutShadowBranch verifies that when an agent
+// commits mid-turn (before SaveStep), attribution is still calculated using HEAD
+// as the shadow tree. This reproduces the bug where agent_lines=0 for mid-turn commits.
+func TestCondenseSession_AttributionWithoutShadowBranch(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial empty commit
+ initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ AllowEmptyCommits: true,
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Agent creates files in nested directories and commits (mid-turn, no SaveStep)
+ srcDir := filepath.Join(dir, "src")
+ if err := os.MkdirAll(srcDir, 0o755); err != nil {
+ t.Fatalf("failed to create src dir: %v", err)
+ }
+ agentFile := filepath.Join(srcDir, "main.go")
+ agentContent := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n"
+ if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil {
+ t.Fatalf("failed to write agent file: %v", err)
+ }
+ agentFile2 := filepath.Join(dir, "README.md")
+ agentContent2 := "# My Project\n\nA test project.\n"
+ if err := os.WriteFile(agentFile2, []byte(agentContent2), 0o644); err != nil {
+ t.Fatalf("failed to write agent file 2: %v", err)
+ }
+ if _, err := worktree.Add("src/main.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ if _, err := worktree.Add("README.md"); err != nil {
+ t.Fatalf("failed to stage file 2: %v", err)
+ }
+ _, err = worktree.Commit("Add project files", &git.CommitOptions{
+ Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Create a live transcript file (required when no shadow branch)
+ transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
+ if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
+ t.Fatalf("failed to create transcript dir: %v", err)
+ }
+ transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
+ transcriptContent := `{"type":"human","message":{"content":"create project files"}}
+{"type":"assistant","message":{"content":"I'll create src/main.go and README.md"}}
+`
+ if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Construct session state manually (no SaveStep was called, so no shadow branch)
+ state := &SessionState{
+ SessionID: "test-no-shadow",
+ BaseCommit: initialHash.String(),
+ AttributionBaseCommit: initialHash.String(),
+ FilesTouched: []string{"src/main.go", "README.md"},
+ TranscriptPath: transcriptFile,
+ AgentType: "Claude Code",
+ }
+
+ s := &ManualCommitStrategy{}
+ checkpointID := id.MustCheckpointID("c3d4e5f6a7b8")
+
+ // Condense — no shadow branch exists, but attribution should still work
+ committedFiles := map[string]struct{}{"src/main.go": {}, "README.md": {}}
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
+ }
+
+ // Read metadata from trace/checkpoints/v1 branch
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(sessionMetadataPath)
+ if err != nil {
+ t.Fatalf("failed to find session metadata at %s: %v", sessionMetadataPath, err)
+ }
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata: %v", err)
+ }
+
+ var metadata struct {
+ InitialAttribution *struct {
+ AgentLines int `json:"agent_lines"`
+ HumanAdded int `json:"human_added"`
+ TotalCommitted int `json:"total_committed"`
+ AgentPercentage float64 `json:"agent_percentage"`
+ } `json:"initial_attribution"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata: %v", err)
+ }
+
+ if metadata.InitialAttribution == nil {
+ t.Fatal("InitialAttribution should be present even without shadow branch")
+ }
+
+ // Agent created all content (10 lines across 2 files), no human edits
+ if metadata.InitialAttribution.AgentLines == 0 {
+ t.Error("AgentLines should be > 0 (agent created the file)")
+ }
+ if metadata.InitialAttribution.TotalCommitted == 0 {
+ t.Error("TotalCommitted should be > 0")
+ }
+ if metadata.InitialAttribution.AgentPercentage <= 50 {
+ t.Errorf("AgentPercentage should be > 50%% (agent wrote all content), got %.1f%%",
+ metadata.InitialAttribution.AgentPercentage)
+ }
+
+ t.Logf("Attribution (no shadow branch): agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
+ metadata.InitialAttribution.AgentLines,
+ metadata.InitialAttribution.HumanAdded,
+ metadata.InitialAttribution.TotalCommitted,
+ metadata.InitialAttribution.AgentPercentage)
+}
+
+// TestCondenseSession_AttributionWithoutShadowBranch_MixedHumanAgent verifies attribution
+// when an agent commits mid-turn (no shadow branch) and the commit includes both human
+// pre-session changes and agent-created files. Human changes are captured in PromptAttributions
+// and should be subtracted from the total to isolate agent contribution.
+func TestCondenseSession_AttributionWithoutShadowBranch_MixedHumanAgent(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit with one file
+ existingFile := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(existingFile, []byte("key: value\n"), 0o644); err != nil {
+ t.Fatalf("failed to write initial file: %v", err)
+ }
+ if _, err := wt.Add("config.yaml"); err != nil {
+ t.Fatalf("failed to stage: %v", err)
+ }
+ initialHash, err := wt.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // Human adds a new file (before the agent session starts).
+ // This is captured by calculatePromptAttributionAtStart.
+ humanFile := filepath.Join(dir, "docs", "notes.md")
+ if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
+ t.Fatalf("failed to mkdir: %v", err)
+ }
+ humanContent := "# Notes\n\nSome human notes.\nAnother line.\n"
+ if err := os.WriteFile(humanFile, []byte(humanContent), 0o644); err != nil {
+ t.Fatalf("failed to write human file: %v", err)
+ }
+
+ // Agent creates its own file in a nested directory
+ if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil {
+ t.Fatalf("failed to mkdir: %v", err)
+ }
+ agentFile := filepath.Join(dir, "src", "app.go")
+ agentContent := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"app\")\n}\n"
+ if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil {
+ t.Fatalf("failed to write agent file: %v", err)
+ }
+
+ // Agent stages everything and commits (mid-turn, no SaveStep)
+ if _, err := wt.Add("docs/notes.md"); err != nil {
+ t.Fatalf("failed to stage: %v", err)
+ }
+ if _, err := wt.Add("src/app.go"); err != nil {
+ t.Fatalf("failed to stage: %v", err)
+ }
+ _, err = wt.Commit("Add app and notes", &git.CommitOptions{
+ Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Create live transcript
+ transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
+ if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
+ t.Fatalf("failed to create transcript dir: %v", err)
+ }
+ transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
+ if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"create src/app.go"}}
+{"type":"assistant","message":{"content":"Done"}}
+`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Session state with PromptAttributions capturing human's pre-session file (4 lines)
+ state := &SessionState{
+ SessionID: "test-mixed-no-shadow",
+ BaseCommit: initialHash.String(),
+ AttributionBaseCommit: initialHash.String(),
+ FilesTouched: []string{"src/app.go"},
+ TranscriptPath: transcriptFile,
+ AgentType: "Claude Code",
+ PromptAttributions: []PromptAttribution{{
+ CheckpointNumber: 1,
+ UserLinesAdded: 4,
+ UserAddedPerFile: map[string]int{"docs/notes.md": 4},
+ }},
+ }
+
+ s := &ManualCommitStrategy{}
+ checkpointID := id.MustCheckpointID("d4e5f6a7b8c9")
+
+ committedFiles := map[string]struct{}{"src/app.go": {}, "docs/notes.md": {}}
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
+ }
+
+ // Read metadata
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(sessionMetadataPath)
+ if err != nil {
+ t.Fatalf("failed to find session metadata at %s: %v", sessionMetadataPath, err)
+ }
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata: %v", err)
+ }
+
+ var metadata struct {
+ InitialAttribution *struct {
+ AgentLines int `json:"agent_lines"`
+ HumanAdded int `json:"human_added"`
+ TotalCommitted int `json:"total_committed"`
+ AgentPercentage float64 `json:"agent_percentage"`
+ } `json:"initial_attribution"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata: %v", err)
+ }
+
+ if metadata.InitialAttribution == nil {
+ t.Fatal("InitialAttribution should be present")
+ }
+
+ attr := metadata.InitialAttribution
+ t.Logf("Attribution (mixed, no shadow): agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
+ attr.AgentLines, attr.HumanAdded, attr.TotalCommitted, attr.AgentPercentage)
+
+ // src/app.go has 7 lines (agent). docs/notes.md was added before the session
+ // (captured by PA1) so it's pre-session baseline — excluded from human count.
+ if attr.AgentLines != 7 {
+ t.Errorf("AgentLines = %d, want 7 (src/app.go has 7 lines)", attr.AgentLines)
+ }
+ if attr.HumanAdded != 0 {
+ t.Errorf("HumanAdded = %d, want 0 (docs/notes.md is pre-session baseline, excluded)", attr.HumanAdded)
+ }
+ if attr.TotalCommitted != 7 {
+ t.Errorf("TotalCommitted = %d, want 7 (agent-only, pre-session excluded)", attr.TotalCommitted)
+ }
+ // Agent wrote 7/7 = 100%
+ if attr.AgentPercentage < 99.0 {
+ t.Errorf("AgentPercentage = %.1f%%, want ~100%% (pre-session human file excluded)", attr.AgentPercentage)
+ }
+}
+
+// TestExtractUserPromptsFromLines tests extraction of user prompts from JSONL format.
+func TestExtractUserPromptsFromLines(t *testing.T) {
+ tests := []struct {
+ name string
+ lines []string
+ expected []string
+ }{
+ {
+ name: "human type message",
+ lines: []string{
+ `{"type":"human","message":{"content":"Hello world"}}`,
+ },
+ expected: []string{"Hello world"},
+ },
+ {
+ name: "user type message",
+ lines: []string{
+ `{"type":"user","message":{"content":"Test prompt"}}`,
+ },
+ expected: []string{"Test prompt"},
+ },
+ {
+ name: "mixed human and assistant",
+ lines: []string{
+ `{"type":"human","message":{"content":"First"}}`,
+ `{"type":"assistant","message":{"content":"Response"}}`,
+ `{"type":"human","message":{"content":"Second"}}`,
+ },
+ expected: []string{"First", "Second"},
+ },
+ {
+ name: "array content",
+ lines: []string{
+ `{"type":"human","message":{"content":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}]}}`,
+ },
+ expected: []string{"Part 1\n\nPart 2"},
+ },
+ {
+ name: "empty lines ignored",
+ lines: []string{
+ `{"type":"human","message":{"content":"Valid"}}`,
+ "",
+ " ",
+ },
+ expected: []string{"Valid"},
+ },
+ {
+ name: "invalid JSON ignored",
+ lines: []string{
+ `{"type":"human","message":{"content":"Valid"}}`,
+ "not json",
+ },
+ expected: []string{"Valid"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := extractUserPromptsFromLines(tt.lines)
+ if len(result) != len(tt.expected) {
+ t.Errorf("extractUserPromptsFromLines() returned %d prompts, want %d", len(result), len(tt.expected))
+ return
+ }
+ for i, prompt := range result {
+ if prompt != tt.expected[i] {
+ t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i])
+ }
+ }
+ })
+ }
+}
diff --git a/cli/strategy/manual_commit_5_test.go b/cli/strategy/manual_commit_5_test.go
new file mode 100644
index 0000000..938193e
--- /dev/null
+++ b/cli/strategy/manual_commit_5_test.go
@@ -0,0 +1,628 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestMultiCheckpoint_UserEditsBetweenCheckpoints tests that user edits made between
+// agent checkpoints are correctly attributed to the user, not the agent.
+//
+// This tests two scenarios:
+// 1. User edits a DIFFERENT file than agent - detected at checkpoint save time
+// 2. User edits the SAME file as agent - detected at commit time (shadow → head diff)
+//
+//nolint:maintidx // Integration test with multiple steps is inherently complex
+func TestMultiCheckpoint_UserEditsBetweenCheckpoints(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit with two files
+ agentFile := filepath.Join(dir, "agent.go")
+ userFile := filepath.Join(dir, "user.go")
+ if err := os.WriteFile(agentFile, []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write agent file: %v", err)
+ }
+ if err := os.WriteFile(userFile, []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write user file: %v", err)
+ }
+ if _, err := worktree.Add("agent.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ if _, err := worktree.Add("user.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-multi-checkpoint-test"
+
+ // Create metadata directory
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ transcript := `{"type":"human","message":{"content":"add function"}}
+{"type":"assistant","message":{"content":"adding function"}}
+`
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // === PROMPT 1 START: Initialize session (simulates UserPromptSubmit) ===
+ // This must happen BEFORE agent makes any changes
+ if err := s.InitializeSession(context.Background(), sessionID, "Claude Code", "", "", ""); err != nil {
+ t.Fatalf("InitializeSession() prompt 1 error = %v", err)
+ }
+
+ // === CHECKPOINT 1: Agent modifies agent.go (adds 4 lines) ===
+ checkpoint1Content := "package main\n\nfunc agentFunc1() {\n\tprintln(\"agent1\")\n}\n"
+ if err := os.WriteFile(agentFile, []byte(checkpoint1Content), 0o644); err != nil {
+ t.Fatalf("failed to write agent changes 1: %v", err)
+ }
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"agent.go"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() checkpoint 1 error = %v", err)
+ }
+
+ // Verify PromptAttribution was recorded for checkpoint 1
+ state1, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() after checkpoint 1 error = %v", err)
+ }
+ if len(state1.PromptAttributions) != 1 {
+ t.Fatalf("expected 1 PromptAttribution after checkpoint 1, got %d", len(state1.PromptAttributions))
+ }
+ // First checkpoint: no user edits yet (user.go hasn't changed)
+ if state1.PromptAttributions[0].UserLinesAdded != 0 {
+ t.Errorf("checkpoint 1: expected 0 user lines added, got %d", state1.PromptAttributions[0].UserLinesAdded)
+ }
+
+ // === USER EDITS A DIFFERENT FILE (user.go) BETWEEN CHECKPOINTS ===
+ userEditContent := "package main\n\n// User added this function\nfunc userFunc() {\n\tprintln(\"user\")\n}\n"
+ if err := os.WriteFile(userFile, []byte(userEditContent), 0o644); err != nil {
+ t.Fatalf("failed to write user edits: %v", err)
+ }
+
+ // === PROMPT 2 START: Initialize session again (simulates UserPromptSubmit) ===
+ // This captures the user's edits to user.go BEFORE the agent runs
+ if err := s.InitializeSession(context.Background(), sessionID, "Claude Code", "", "", ""); err != nil {
+ t.Fatalf("InitializeSession() prompt 2 error = %v", err)
+ }
+
+ // === CHECKPOINT 2: Agent modifies agent.go again (adds 4 more lines) ===
+ checkpoint2Content := "package main\n\nfunc agentFunc1() {\n\tprintln(\"agent1\")\n}\n\nfunc agentFunc2() {\n\tprintln(\"agent2\")\n}\n"
+ if err := os.WriteFile(agentFile, []byte(checkpoint2Content), 0o644); err != nil {
+ t.Fatalf("failed to write agent changes 2: %v", err)
+ }
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"agent.go"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() checkpoint 2 error = %v", err)
+ }
+
+ // Verify PromptAttribution was recorded for checkpoint 2
+ state2, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() after checkpoint 2 error = %v", err)
+ }
+ if len(state2.PromptAttributions) != 2 {
+ t.Fatalf("expected 2 PromptAttributions after checkpoint 2, got %d", len(state2.PromptAttributions))
+ }
+
+ t.Logf("Checkpoint 2 PromptAttribution: user_added=%d, user_removed=%d, agent_added=%d, agent_removed=%d",
+ state2.PromptAttributions[1].UserLinesAdded,
+ state2.PromptAttributions[1].UserLinesRemoved,
+ state2.PromptAttributions[1].AgentLinesAdded,
+ state2.PromptAttributions[1].AgentLinesRemoved)
+
+ // Second checkpoint should detect user's edits to user.go (different file than agent)
+ // User added 5 lines to user.go
+ if state2.PromptAttributions[1].UserLinesAdded == 0 {
+ t.Error("checkpoint 2: expected user lines added > 0 because user edited user.go")
+ }
+
+ // === USER COMMITS ===
+ if _, err := worktree.Add("agent.go"); err != nil {
+ t.Fatalf("failed to stage agent.go: %v", err)
+ }
+ if _, err := worktree.Add("user.go"); err != nil {
+ t.Fatalf("failed to stage user.go: %v", err)
+ }
+ _, err = worktree.Commit("Final commit with agent and user changes", &git.CommitOptions{
+ Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ // === CONDENSE AND VERIFY ATTRIBUTION ===
+ checkpointID := id.MustCheckpointID("b2c3d4e5f6a7")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state2, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
+ }
+
+ // Read metadata and verify attribution
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ // InitialAttribution is stored in session-level metadata (0/metadata.json), not root (0-based indexing)
+ sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(sessionMetadataPath)
+ if err != nil {
+ t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
+ }
+
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata.json: %v", err)
+ }
+
+ var metadata struct {
+ InitialAttribution *struct {
+ AgentLines int `json:"agent_lines"`
+ HumanAdded int `json:"human_added"`
+ HumanModified int `json:"human_modified"`
+ HumanRemoved int `json:"human_removed"`
+ TotalCommitted int `json:"total_committed"`
+ AgentPercentage float64 `json:"agent_percentage"`
+ } `json:"initial_attribution"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata.json: %v", err)
+ }
+
+ if metadata.InitialAttribution == nil {
+ t.Fatal("InitialAttribution should be present in session metadata")
+ }
+
+ t.Logf("Final Attribution: agent=%d, human_added=%d, human_modified=%d, human_removed=%d, total=%d, percentage=%.1f%%",
+ metadata.InitialAttribution.AgentLines,
+ metadata.InitialAttribution.HumanAdded,
+ metadata.InitialAttribution.HumanModified,
+ metadata.InitialAttribution.HumanRemoved,
+ metadata.InitialAttribution.TotalCommitted,
+ metadata.InitialAttribution.AgentPercentage)
+
+ // Verify the attribution makes sense:
+ // - Agent modified agent.go: added ~8 lines total
+ // - User modified user.go: added ~5 lines
+ // - So agent percentage should be around 50-70%
+ if metadata.InitialAttribution.AgentLines == 0 {
+ t.Error("AgentLines should be > 0")
+ }
+ if metadata.InitialAttribution.TotalCommitted == 0 {
+ t.Error("TotalCommitted should be > 0")
+ }
+
+ // The key test: user's lines should be captured in HumanAdded
+ if metadata.InitialAttribution.HumanAdded == 0 {
+ t.Error("HumanAdded should be > 0 because user added lines to user.go")
+ }
+
+ // Agent percentage should not be 100% since user contributed
+ if metadata.InitialAttribution.AgentPercentage >= 100 {
+ t.Errorf("AgentPercentage should be < 100%% since user contributed, got %.1f%%",
+ metadata.InitialAttribution.AgentPercentage)
+ }
+}
+
+// TestCondenseSession_PrefersLiveTranscript verifies that CondenseSession reads the
+// live transcript file when available, rather than the potentially stale shadow branch copy.
+// This reproduces the bug where SaveStep was skipped (no code changes) but the
+// transcript continued growing — deferred condensation would read stale data.
+func TestCondenseSession_PrefersLiveTranscript(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ // Create initial commit
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := wt.Add("file.txt"); err != nil {
+ t.Fatalf("failed to stage: %v", err)
+ }
+ _, err = wt.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-live-transcript"
+
+ // Create metadata dir with an initial (short) transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ staleTranscript := `{"type":"human","message":{"content":"first prompt"}}
+{"type":"assistant","message":{"content":"first response"}}
+`
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(staleTranscript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // SaveStep to create shadow branch with the stale transcript
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Now simulate the conversation continuing: write a LONGER live transcript file.
+ // In the real bug, SaveStep would be skipped because totalChanges == 0,
+ // so the shadow branch still has the stale version.
+ liveTranscriptFile := filepath.Join(dir, "live-transcript.jsonl")
+ liveTranscript := `{"type":"human","message":{"content":"first prompt"}}
+{"type":"assistant","message":{"content":"first response"}}
+{"type":"human","message":{"content":"second prompt"}}
+{"type":"assistant","message":{"content":"second response"}}
+`
+ if err := os.WriteFile(liveTranscriptFile, []byte(liveTranscript), 0o644); err != nil {
+ t.Fatalf("failed to write live transcript: %v", err)
+ }
+
+ // Load session state and set TranscriptPath to the live file
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ state.TranscriptPath = liveTranscriptFile
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ // Condense — this should read the live transcript, not the shadow branch copy
+ checkpointID := id.MustCheckpointID("b2c3d4e5f6a1")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // The live transcript has 4 lines; the shadow branch copy has 2.
+ // If we read the stale shadow copy, we'd only see 2 lines.
+ if result.TotalTranscriptLines != 4 {
+ t.Errorf("TotalTranscriptLines = %d, want 4 (live transcript has 4 lines, shadow has 2)", result.TotalTranscriptLines)
+ }
+
+ // Verify the condensed content includes the second prompt
+ store := checkpoint.NewGitStore(repo)
+ content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadLatestSessionContent() error = %v", err)
+ }
+ if !strings.Contains(string(content.Transcript), "second prompt") {
+ t.Error("condensed transcript should contain 'second prompt' from live file, but it doesn't")
+ }
+}
+
+// TestCondenseSession_TranscriptRelocatedMidSession verifies that CondenseSession
+// succeeds when the agent relocates its transcript mid-session (e.g., Cursor CLI
+// switching from flat /.jsonl to nested //.jsonl layout).
+// This is a regression test for a Cursor CLI 2026.03.11 change that broke mid-turn
+// commits because the stored TranscriptPath became stale.
+func TestCondenseSession_TranscriptRelocatedMidSession(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ wt, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := wt.Add("file.txt"); err != nil {
+ t.Fatalf("failed to stage: %v", err)
+ }
+ _, err = wt.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "87874108-eff2-47a0-b260-183961dd6cb0"
+
+ // Create the session state with a flat TranscriptPath (what before-submit-prompt reports)
+ agentTranscriptsDir := filepath.Join(dir, "agent-transcripts")
+ if err := os.MkdirAll(agentTranscriptsDir, 0o755); err != nil {
+ t.Fatalf("failed to create agent-transcripts dir: %v", err)
+ }
+ flatPath := filepath.Join(agentTranscriptsDir, sessionID+".jsonl")
+
+ // But the file actually lives at the nested path (Cursor relocated it)
+ nestedDir := filepath.Join(agentTranscriptsDir, sessionID)
+ if err := os.MkdirAll(nestedDir, 0o755); err != nil {
+ t.Fatalf("failed to create nested dir: %v", err)
+ }
+ nestedPath := filepath.Join(nestedDir, sessionID+".jsonl")
+ transcript := `{"type":"human","message":{"content":"create a file"}}
+{"type":"assistant","message":{"content":"done"}}
+`
+ if err := os.WriteFile(nestedPath, []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Create session state pointing to the FLAT (stale) path
+ head, err := repo.Head()
+ if err != nil {
+ t.Fatalf("failed to get HEAD: %v", err)
+ }
+ state := &SessionState{
+ SessionID: sessionID,
+ BaseCommit: head.Hash().String(),
+ WorktreePath: dir,
+ AgentType: agent.AgentTypeCursor,
+ TranscriptPath: flatPath, // stale: file was relocated to nested path
+ }
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("saveSessionState() error = %v", err)
+ }
+
+ // CondenseSession should succeed by re-resolving the transcript path
+ checkpointID := id.MustCheckpointID("c1d2e3f4a5b6")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v, want nil (should re-resolve stale transcript path)", err)
+ }
+
+ if result.TotalTranscriptLines != 2 {
+ t.Errorf("TotalTranscriptLines = %d, want 2", result.TotalTranscriptLines)
+ }
+
+ // State should have been updated to the resolved path
+ if state.TranscriptPath != nestedPath {
+ t.Errorf("state.TranscriptPath = %q, want %q (should be updated after re-resolution)", state.TranscriptPath, nestedPath)
+ }
+}
+
+// TestCondenseSession_GeminiTranscript verifies that CondenseSession works correctly
+// with Gemini JSON format transcripts, including prompt extraction and format detection.
+func TestCondenseSession_GeminiTranscript(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ testFile := filepath.Join(dir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("initial content"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("test.txt"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2026-02-09-gemini-test"
+
+ // Create metadata directory with Gemini JSON transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ // Gemini JSON format with IDE tags to test stripping
+ geminiTranscript := `{
+ "sessionId": "test-session",
+ "messages": [
+ {
+ "type": "user",
+ "content": "test.txtCreate a new file"
+ },
+ {
+ "type": "gemini",
+ "content": "I'll create the file for you",
+ "tokens": {
+ "input": 50,
+ "output": 20,
+ "cached": 10
+ }
+ }
+ ]
+ }`
+
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(geminiTranscript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Write prompt.txt (simulating what lifecycle does at turn start / turn end)
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Create a new file"), 0o644); err != nil {
+ t.Fatalf("failed to write prompt file: %v", err)
+ }
+
+ // Create modified file
+ if err := os.WriteFile(testFile, []byte("modified by gemini"), 0o644); err != nil {
+ t.Fatalf("failed to modify file: %v", err)
+ }
+
+ // Save checkpoint (creates shadow branch)
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.txt"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Gemini CLI",
+ AuthorEmail: "gemini@test.com",
+ AgentType: agent.AgentTypeGemini,
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() error = %v", err)
+ }
+
+ // Load session state
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if state.AgentType != agent.AgentTypeGemini {
+ t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini)
+ }
+
+ // Condense the session
+ checkpointID := id.MustCheckpointID("aabbcc112233")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Verify result
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %v, want %v", result.CheckpointID, checkpointID)
+ }
+ if result.SessionID != sessionID {
+ t.Errorf("SessionID = %q, want %q", result.SessionID, sessionID)
+ }
+ if len(result.FilesTouched) != 1 || result.FilesTouched[0] != "test.txt" {
+ t.Errorf("FilesTouched = %v, want [test.txt]", result.FilesTouched)
+ }
+
+ // Verify condensed data on trace/checkpoints/v1 branch
+ store := checkpoint.NewGitStore(repo)
+ content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadLatestSessionContent() error = %v", err)
+ }
+
+ // Verify transcript was stored
+ if len(content.Transcript) == 0 {
+ t.Error("Transcript should not be empty")
+ }
+
+ // Verify prompts were extracted and IDE tags were stripped
+ if !strings.Contains(content.Prompts, "Create a new file") {
+ t.Errorf("Prompts = %q, should contain %q (IDE tags should be stripped)", content.Prompts, "Create a new file")
+ }
+ if strings.Contains(content.Prompts, "") {
+ t.Error("Prompts should not contain IDE tags")
+ }
+
+ // Verify token usage was calculated
+ if content.Metadata.TokenUsage == nil {
+ t.Fatal("TokenUsage should not be nil for Gemini transcript")
+ }
+ if content.Metadata.TokenUsage.InputTokens != 50 {
+ t.Errorf("InputTokens = %d, want 50", content.Metadata.TokenUsage.InputTokens)
+ }
+ if content.Metadata.TokenUsage.OutputTokens != 20 {
+ t.Errorf("OutputTokens = %d, want 20", content.Metadata.TokenUsage.OutputTokens)
+ }
+ if content.Metadata.TokenUsage.CacheReadTokens != 10 {
+ t.Errorf("CacheReadTokens = %d, want 10", content.Metadata.TokenUsage.CacheReadTokens)
+ }
+}
diff --git a/cli/strategy/manual_commit_6_test.go b/cli/strategy/manual_commit_6_test.go
new file mode 100644
index 0000000..c0c8a57
--- /dev/null
+++ b/cli/strategy/manual_commit_6_test.go
@@ -0,0 +1,745 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// TestCondenseSession_GeminiMultiCheckpoint verifies that multi-checkpoint Gemini sessions
+// correctly scope token usage to only the checkpoint portion (not the trace transcript).
+// This is the core bug fix - ensuring CheckpointTranscriptStart is properly used.
+//
+//nolint:maintidx // Integration test with comprehensive verification steps
+func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ testFile := filepath.Join(dir, "code.go")
+ if err := os.WriteFile(testFile, []byte("package main"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("code.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ _, err = worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ if err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2026-02-09-multi-checkpoint"
+
+ // Create metadata directory
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
+ t.Fatalf("failed to create metadata dir: %v", err)
+ }
+
+ transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+
+ // CHECKPOINT 1: Initial work with 2 messages (1 gemini message with tokens)
+ checkpoint1Transcript := `{
+ "sessionId": "multi-test",
+ "messages": [
+ {
+ "type": "user",
+ "content": "Add a main function"
+ },
+ {
+ "type": "gemini",
+ "content": "I'll add a main function",
+ "tokens": {
+ "input": 100,
+ "output": 50,
+ "cached": 20
+ }
+ }
+ ]
+ }`
+
+ if err := os.WriteFile(transcriptPath, []byte(checkpoint1Transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Write prompt.txt for checkpoint 1 (simulating what lifecycle does)
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Add a main function"), 0o644); err != nil {
+ t.Fatalf("failed to write prompt file: %v", err)
+ }
+
+ // Modify file for checkpoint 1
+ if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
+ t.Fatalf("failed to modify file: %v", err)
+ }
+
+ // Save checkpoint 1
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"code.go"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Gemini CLI",
+ AuthorEmail: "gemini@test.com",
+ AgentType: agent.AgentTypeGemini,
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() checkpoint 1 error = %v", err)
+ }
+
+ // Load and verify state after checkpoint 1
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+ if state.CheckpointTranscriptStart != 0 {
+ t.Errorf("CheckpointTranscriptStart after checkpoint 1 = %d, want 0", state.CheckpointTranscriptStart)
+ }
+
+ // CHECKPOINT 2: Add more messages to transcript (simulating continued session)
+ // This adds 2 more messages (indices 2 and 3), with new token counts
+ checkpoint2Transcript := `{
+ "sessionId": "multi-test",
+ "messages": [
+ {
+ "type": "user",
+ "content": "Add a main function"
+ },
+ {
+ "type": "gemini",
+ "content": "I'll add a main function",
+ "tokens": {
+ "input": 100,
+ "output": 50,
+ "cached": 20
+ }
+ },
+ {
+ "type": "user",
+ "content": "Now add error handling"
+ },
+ {
+ "type": "gemini",
+ "content": "I'll add error handling",
+ "tokens": {
+ "input": 200,
+ "output": 75,
+ "cached": 30
+ }
+ }
+ ]
+ }`
+
+ if err := os.WriteFile(transcriptPath, []byte(checkpoint2Transcript), 0o644); err != nil {
+ t.Fatalf("failed to update transcript: %v", err)
+ }
+
+ // Simulate condensation clearing prompt.txt (condenseAndUpdateState does this),
+ // then lifecycle appending the new prompt at turn start.
+ if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Now add error handling"), 0o644); err != nil {
+ t.Fatalf("failed to write prompt file: %v", err)
+ }
+
+ // Modify file for checkpoint 2
+ if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tpanic(err)\n\t}\n}\n"), 0o644); err != nil {
+ t.Fatalf("failed to modify file: %v", err)
+ }
+
+ // Before checkpoint 2, manually update CheckpointTranscriptStart to simulate
+ // what would happen after condensing checkpoint 1
+ state.CheckpointTranscriptStart = 2 // Start from message index 2 (the second user prompt)
+ state.StepCount = 1 // Set to 1 (will be incremented to 2 by SaveStep)
+ if err := s.saveSessionState(context.Background(), state); err != nil {
+ t.Fatalf("failed to update session state: %v", err)
+ }
+
+ // Save checkpoint 2
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"code.go"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Gemini CLI",
+ AuthorEmail: "gemini@test.com",
+ AgentType: agent.AgentTypeGemini,
+ })
+ if err != nil {
+ t.Fatalf("SaveStep() checkpoint 2 error = %v", err)
+ }
+
+ // Reload state to get updated values
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("loadSessionState() error = %v", err)
+ }
+
+ // Condense the session - this should calculate token usage ONLY from message index 2 onwards
+ checkpointID := id.MustCheckpointID("ddeeff998877")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Verify result
+ if result.CheckpointsCount != 2 {
+ t.Errorf("CheckpointsCount = %d, want 2", result.CheckpointsCount)
+ }
+ if result.TotalTranscriptLines != 4 {
+ t.Errorf("TotalTranscriptLines = %d, want 4 (4 messages in Gemini format)", result.TotalTranscriptLines)
+ }
+
+ // Read condensed metadata
+ store := checkpoint.NewGitStore(repo)
+ content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadLatestSessionContent() error = %v", err)
+ }
+
+ // CRITICAL VERIFICATION: Token usage should ONLY count from message index 2 onwards
+ // This means ONLY the second gemini message (indices 2-3), NOT the first one (indices 0-1)
+ if content.Metadata.TokenUsage == nil {
+ t.Fatal("TokenUsage should not be nil")
+ }
+
+ // Expected: Only the second gemini message tokens (input=200, output=75, cached=30)
+ // NOT the first gemini message tokens (input=100, output=50, cached=20)
+ if content.Metadata.TokenUsage.InputTokens != 200 {
+ t.Errorf("InputTokens = %d, want 200 (should only count from checkpoint start, not trace transcript)",
+ content.Metadata.TokenUsage.InputTokens)
+ }
+ if content.Metadata.TokenUsage.OutputTokens != 75 {
+ t.Errorf("OutputTokens = %d, want 75 (should only count from checkpoint start, not trace transcript)",
+ content.Metadata.TokenUsage.OutputTokens)
+ }
+ if content.Metadata.TokenUsage.CacheReadTokens != 30 {
+ t.Errorf("CacheReadTokens = %d, want 30 (should only count from checkpoint start, not trace transcript)",
+ content.Metadata.TokenUsage.CacheReadTokens)
+ }
+ if content.Metadata.TokenUsage.APICallCount != 1 {
+ t.Errorf("APICallCount = %d, want 1 (only one gemini message after checkpoint start)",
+ content.Metadata.TokenUsage.APICallCount)
+ }
+
+ // Verify the full transcript is stored (all 4 messages)
+ if len(content.Transcript) == 0 {
+ t.Error("Full transcript should be stored")
+ }
+
+ // Verify only checkpoint-scoped prompts are present (from CheckpointTranscriptStart onwards)
+ if strings.Contains(content.Prompts, "Add a main function") {
+ t.Error("Prompts should NOT contain first prompt (before checkpoint start)")
+ }
+ if !strings.Contains(content.Prompts, "Now add error handling") {
+ t.Error("Prompts should contain second prompt (checkpoint-scoped)")
+ }
+}
+
+func TestCondenseSession_CopilotScopedCheckpointMetadataAndSessionBackfill(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ AllowEmptyCommits: true,
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ sessionID := "2026-03-17-copilot-token-scope"
+ transcriptDir := filepath.Join(dir, ".copilot", "session-state", sessionID)
+ if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
+ t.Fatalf("failed to create transcript dir: %v", err)
+ }
+ transcriptPath := filepath.Join(transcriptDir, "events.jsonl")
+
+ transcript := strings.Join([]string{
+ `{"type":"session.start","data":{"sessionId":"2026-03-17-copilot-token-scope"},"id":"1","timestamp":"2026-03-17T00:00:00Z","parentId":""}`,
+ `{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"id":"2","timestamp":"2026-03-17T00:00:01Z","parentId":"1"}`,
+ `{"type":"user.message","data":{"content":"Create alpha.txt"},"id":"3","timestamp":"2026-03-17T00:00:02Z","parentId":""}`,
+ `{"type":"assistant.message","data":{"content":"Created alpha.txt","outputTokens":10},"id":"4","timestamp":"2026-03-17T00:00:03Z","parentId":"3"}`,
+ `{"type":"tool.execution_complete","data":{"toolCallId":"tool-1","model":"claude-sonnet-4.6","toolTelemetry":{"properties":{"filePaths":"[\"alpha.txt\"]"},"metrics":{"linesAdded":1,"linesRemoved":0}}},"id":"5","timestamp":"2026-03-17T00:00:04Z","parentId":"4"}`,
+ `{"type":"user.message","data":{"content":"Create beta.txt"},"id":"6","timestamp":"2026-03-17T00:00:05Z","parentId":""}`,
+ `{"type":"assistant.message","data":{"content":"Created beta.txt","outputTokens":25},"id":"7","timestamp":"2026-03-17T00:00:06Z","parentId":"6"}`,
+ `{"type":"tool.execution_complete","data":{"toolCallId":"tool-2","model":"claude-sonnet-4.6","toolTelemetry":{"properties":{"filePaths":"[\"beta.txt\"]"},"metrics":{"linesAdded":1,"linesRemoved":0}}},"id":"8","timestamp":"2026-03-17T00:00:07Z","parentId":"7"}`,
+ `{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"requests":{"count":2},"usage":{"inputTokens":0,"outputTokens":35,"cacheReadTokens":20,"cacheWriteTokens":10}}}},"id":"9","timestamp":"2026-03-17T00:00:08Z","parentId":""}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ state := &SessionState{
+ SessionID: sessionID,
+ BaseCommit: initialHash.String(),
+ StartedAt: time.Now(),
+ FilesTouched: []string{"beta.txt"},
+ WorktreePath: dir,
+ TranscriptPath: transcriptPath,
+ AgentType: agent.AgentTypeCopilotCLI,
+ ModelName: "claude-sonnet-4.6",
+ CheckpointTranscriptStart: 5,
+ }
+
+ s := &ManualCommitStrategy{}
+ checkpointID := id.MustCheckpointID("cc11aa22bb33")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ if result.CheckpointID != checkpointID {
+ t.Errorf("CheckpointID = %v, want %v", result.CheckpointID, checkpointID)
+ }
+ if len(result.FilesTouched) != 1 || result.FilesTouched[0] != "beta.txt" {
+ t.Errorf("FilesTouched = %v, want [beta.txt]", result.FilesTouched)
+ }
+
+ store := checkpoint.NewGitStore(repo)
+ content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
+ if err != nil {
+ t.Fatalf("ReadLatestSessionContent() error = %v", err)
+ }
+
+ if content.Metadata.TokenUsage == nil {
+ t.Fatal("TokenUsage should not be nil")
+ }
+ if content.Metadata.TokenUsage.InputTokens != 0 {
+ t.Errorf("metadata InputTokens = %d, want 0 for scoped Copilot checkpoint usage", content.Metadata.TokenUsage.InputTokens)
+ }
+ if content.Metadata.TokenUsage.OutputTokens != 25 {
+ t.Errorf("metadata OutputTokens = %d, want 25 for second checkpoint assistant output", content.Metadata.TokenUsage.OutputTokens)
+ }
+ if content.Metadata.TokenUsage.CacheReadTokens != 0 {
+ t.Errorf("metadata CacheReadTokens = %d, want 0 for scoped fallback path", content.Metadata.TokenUsage.CacheReadTokens)
+ }
+ if content.Metadata.TokenUsage.CacheCreationTokens != 0 {
+ t.Errorf("metadata CacheCreationTokens = %d, want 0 for scoped fallback path", content.Metadata.TokenUsage.CacheCreationTokens)
+ }
+ if content.Metadata.TokenUsage.APICallCount != 1 {
+ t.Errorf("metadata APICallCount = %d, want 1", content.Metadata.TokenUsage.APICallCount)
+ }
+
+ if state.TokenUsage == nil {
+ t.Fatal("state.TokenUsage should not be nil after Copilot session backfill")
+ }
+ if state.TokenUsage.InputTokens != 0 {
+ t.Errorf("state InputTokens = %d, want 0 from session.shutdown", state.TokenUsage.InputTokens)
+ }
+ if state.TokenUsage.OutputTokens != 35 {
+ t.Errorf("state OutputTokens = %d, want 35 from session.shutdown", state.TokenUsage.OutputTokens)
+ }
+ if state.TokenUsage.CacheReadTokens != 20 {
+ t.Errorf("state CacheReadTokens = %d, want 20 from session.shutdown", state.TokenUsage.CacheReadTokens)
+ }
+ if state.TokenUsage.CacheCreationTokens != 10 {
+ t.Errorf("state CacheCreationTokens = %d, want 10 from session.shutdown", state.TokenUsage.CacheCreationTokens)
+ }
+ if state.TokenUsage.APICallCount != 2 {
+ t.Errorf("state APICallCount = %d, want 2 from session.shutdown", state.TokenUsage.APICallCount)
+ }
+}
+
+// TestCondenseSession_FilesTouchedFallback_EmptyState verifies that when state.FilesTouched
+// is empty (mid-session commit before SaveStep), the fallback to committedFiles works.
+// This is the legitimate use case for the fallback.
+func TestCondenseSession_FilesTouchedFallback_EmptyState(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ AllowEmptyCommits: true,
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create a file and commit it (simulating agent mid-turn commit)
+ agentFile := filepath.Join(dir, "agent.go")
+ if err := os.WriteFile(agentFile, []byte("package main\n"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+ if _, err := worktree.Add("agent.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ if _, err = worktree.Commit("Add agent.go", &git.CommitOptions{
+ Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Create live transcript (required when no shadow branch)
+ transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
+ if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
+ t.Fatalf("failed to create transcript dir: %v", err)
+ }
+ transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
+ if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"create agent.go"}}
+{"type":"assistant","message":{"content":"Done"}}
+`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Session state with EMPTY FilesTouched (mid-session commit scenario)
+ state := &SessionState{
+ SessionID: "test-empty-files",
+ BaseCommit: initialHash.String(),
+ FilesTouched: []string{}, // Empty - no SaveStep called yet
+ TranscriptPath: transcriptFile,
+ AgentType: "Claude Code",
+ }
+
+ s := &ManualCommitStrategy{}
+ checkpointID := id.MustCheckpointID("fa11bac00001")
+
+ // Condense with committedFiles - should fallback since FilesTouched is empty
+ committedFiles := map[string]struct{}{"agent.go": {}}
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Read metadata and verify files_touched contains the committed file (fallback worked)
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(metadataPath)
+ if err != nil {
+ t.Fatalf("failed to find metadata: %v", err)
+ }
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata: %v", err)
+ }
+
+ var metadata struct {
+ FilesTouched []string `json:"files_touched"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata: %v", err)
+ }
+
+ // Verify fallback worked - files_touched should contain agent.go
+ if len(metadata.FilesTouched) != 1 || metadata.FilesTouched[0] != "agent.go" {
+ t.Errorf("files_touched = %v, want [agent.go] (fallback should apply when FilesTouched is empty)",
+ metadata.FilesTouched)
+ }
+
+ t.Logf("Fallback worked: files_touched = %v, result = %+v", metadata.FilesTouched, result)
+}
+
+// TestCondenseSession_FilesTouchedNoFallback_NoOverlap verifies that when state.FilesTouched
+// has files but none overlap with committedFiles, we do NOT fallback to committedFiles.
+// This prevents the bug where unrelated sessions get incorrect files_touched.
+func TestCondenseSession_FilesTouchedNoFallback_NoOverlap(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ if err != nil {
+ t.Fatalf("failed to init repo: %v", err)
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ t.Fatalf("failed to get worktree: %v", err)
+ }
+
+ // Create initial commit
+ initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ AllowEmptyCommits: true,
+ })
+ if err != nil {
+ t.Fatalf("failed to create initial commit: %v", err)
+ }
+
+ // Create files for both the session's work and the committed file
+ sessionFile := filepath.Join(dir, "session_file.go")
+ if err := os.WriteFile(sessionFile, []byte("package session\n"), 0o644); err != nil {
+ t.Fatalf("failed to write session file: %v", err)
+ }
+ committedFile := filepath.Join(dir, "other_file.go")
+ if err := os.WriteFile(committedFile, []byte("package other\n"), 0o644); err != nil {
+ t.Fatalf("failed to write committed file: %v", err)
+ }
+
+ // Only commit the "other" file (not the session's file)
+ if _, err := worktree.Add("other_file.go"); err != nil {
+ t.Fatalf("failed to stage file: %v", err)
+ }
+ if _, err = worktree.Commit("Add other_file.go", &git.CommitOptions{
+ Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to commit: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ // Create live transcript
+ transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
+ if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
+ t.Fatalf("failed to create transcript dir: %v", err)
+ }
+ transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
+ if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"work on session_file.go"}}
+{"type":"assistant","message":{"content":"Done"}}
+`), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Session state with FilesTouched that does NOT overlap with committedFiles
+ state := &SessionState{
+ SessionID: "test-no-overlap",
+ BaseCommit: initialHash.String(),
+ FilesTouched: []string{"session_file.go"}, // Does NOT overlap with other_file.go
+ TranscriptPath: transcriptFile,
+ AgentType: "Claude Code",
+ }
+
+ s := &ManualCommitStrategy{}
+ checkpointID := id.MustCheckpointID("00001a000001")
+
+ // Condense with committedFiles that don't overlap
+ committedFiles := map[string]struct{}{"other_file.go": {}}
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
+ if err != nil {
+ t.Fatalf("CondenseSession() error = %v", err)
+ }
+
+ // Read metadata and verify files_touched is EMPTY (no fallback applied)
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ if err != nil {
+ t.Fatalf("failed to get sessions branch: %v", err)
+ }
+ sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
+ if err != nil {
+ t.Fatalf("failed to get sessions commit: %v", err)
+ }
+ tree, err := sessionsCommit.Tree()
+ if err != nil {
+ t.Fatalf("failed to get tree: %v", err)
+ }
+
+ metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
+ metadataFile, err := tree.File(metadataPath)
+ if err != nil {
+ t.Fatalf("failed to find metadata: %v", err)
+ }
+ content, err := metadataFile.Contents()
+ if err != nil {
+ t.Fatalf("failed to read metadata: %v", err)
+ }
+
+ var metadata struct {
+ FilesTouched []string `json:"files_touched"`
+ }
+ if err := json.Unmarshal([]byte(content), &metadata); err != nil {
+ t.Fatalf("failed to parse metadata: %v", err)
+ }
+
+ // Verify NO fallback - files_touched should be EMPTY, NOT contain other_file.go
+ // This is the key fix: session had files (session_file.go) but none overlapped,
+ // so we should NOT fallback to committedFiles (other_file.go)
+ if len(metadata.FilesTouched) != 0 {
+ t.Errorf("files_touched = %v, want [] (should NOT fallback when session had files but no overlap)",
+ metadata.FilesTouched)
+ }
+
+ t.Logf("No fallback applied: files_touched = %v (correctly empty), result = %+v", metadata.FilesTouched, result)
+}
+
+// TestExtractFilesFromLiveTranscript_RespectsOffset verifies that after condensation
+// sets CheckpointTranscriptStart = N, resolveFilesTouched only returns
+// files from messages at index N and beyond, not from the beginning.
+//
+// This is a regression test for a bug where compaction events (pre-compress hooks)
+// unconditionally reset CheckpointTranscriptStart to 0, causing already-condensed
+// files to re-appear in carry-forward and break sequential commit scenarios.
+func TestExtractFilesFromLiveTranscript_RespectsOffset(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ // Create a Gemini-format transcript with 3 file writes at different message indices:
+ // msg 0: user prompt
+ // msg 1: gemini writes red.md (already condensed)
+ // msg 2: user prompt
+ // msg 3: gemini writes blue.md (already condensed)
+ // msg 4: user prompt
+ // msg 5: gemini writes green.md (new, should be extracted)
+ transcript := `{
+ "messages": [
+ {"type": "user", "content": [{"text": "create red.md"}]},
+ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/red.md"}}]},
+ {"type": "user", "content": [{"text": "create blue.md"}]},
+ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/blue.md"}}]},
+ {"type": "user", "content": [{"text": "create green.md"}]},
+ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/green.md"}}]}
+ ]
+}`
+
+ transcriptPath := filepath.Join(dir, "transcript.json")
+ if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ // Simulate state after 2 condensations: offset points past blue.md's message
+ state := &SessionState{
+ SessionID: "test-offset-session",
+ TranscriptPath: transcriptPath,
+ AgentType: agent.AgentTypeGemini,
+ WorktreePath: dir,
+ CheckpointTranscriptStart: 4, // Past red.md (msg 1) and blue.md (msg 3)
+ }
+
+ // With correct offset (4): should only find green.md
+ files := s.resolveFilesTouched(context.Background(), state)
+ if len(files) != 1 || files[0] != "docs/green.md" {
+ t.Errorf("resolveFilesTouched(offset=4) = %v, want [docs/green.md]", files)
+ }
+
+ // With reset offset (0): would incorrectly find all 3 files (the bug)
+ state.CheckpointTranscriptStart = 0
+ allFiles := s.resolveFilesTouched(context.Background(), state)
+ if len(allFiles) != 3 {
+ t.Errorf("resolveFilesTouched(offset=0) got %d files, want 3: %v", len(allFiles), allFiles)
+ }
+}
+
+// TestResolveFilesTouched_PrefersStateFallsBackToTranscript verifies the two-tier
+// resolution in resolveFilesTouched: state.FilesTouched is preferred (returns a copy),
+// and transcript extraction is only used as a fallback when FilesTouched is empty.
+func TestResolveFilesTouched_PrefersStateFallsBackToTranscript(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ // Gemini transcript containing a file write
+ transcript := `{
+ "messages": [
+ {"type": "user", "content": [{"text": "create file"}]},
+ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "from-transcript.txt"}}]}
+ ]
+}`
+ transcriptPath := filepath.Join(dir, "transcript.json")
+ if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
+ t.Fatalf("failed to write transcript: %v", err)
+ }
+
+ t.Run("prefers FilesTouched over transcript", func(t *testing.T) {
+ state := &SessionState{
+ SessionID: "test-prefers-state",
+ TranscriptPath: transcriptPath,
+ AgentType: agent.AgentTypeGemini,
+ WorktreePath: dir,
+ FilesTouched: []string{"from-hook.txt"},
+ }
+ files := s.resolveFilesTouched(context.Background(), state)
+ if len(files) != 1 || files[0] != "from-hook.txt" {
+ t.Errorf("resolveFilesTouched with FilesTouched = %v, want [from-hook.txt]", files)
+ }
+ })
+
+ t.Run("returns copy of FilesTouched", func(t *testing.T) {
+ state := &SessionState{
+ SessionID: "test-copy",
+ FilesTouched: []string{"a.txt", "b.txt"},
+ }
+ files := s.resolveFilesTouched(context.Background(), state)
+ // Mutating returned slice should not affect state
+ files[0] = "mutated.txt"
+ if state.FilesTouched[0] != "a.txt" {
+ t.Errorf("resolveFilesTouched did not return a copy; state.FilesTouched[0] = %q", state.FilesTouched[0])
+ }
+ })
+
+ t.Run("falls back to transcript when FilesTouched is empty", func(t *testing.T) {
+ state := &SessionState{
+ SessionID: "test-fallback",
+ TranscriptPath: transcriptPath,
+ AgentType: agent.AgentTypeGemini,
+ WorktreePath: dir,
+ FilesTouched: nil,
+ }
+ files := s.resolveFilesTouched(context.Background(), state)
+ if len(files) != 1 || files[0] != "from-transcript.txt" {
+ t.Errorf("resolveFilesTouched with empty FilesTouched = %v, want [from-transcript.txt]", files)
+ }
+ })
+
+ t.Run("returns nil when both sources are empty", func(t *testing.T) {
+ state := &SessionState{
+ SessionID: "test-empty",
+ FilesTouched: nil,
+ // No transcript path — extraction will return nil
+ }
+ files := s.resolveFilesTouched(context.Background(), state)
+ if files != nil {
+ t.Errorf("resolveFilesTouched with no sources = %v, want nil", files)
+ }
+ })
+}
diff --git a/cli/strategy/manual_commit_7_test.go b/cli/strategy/manual_commit_7_test.go
new file mode 100644
index 0000000..782b5bf
--- /dev/null
+++ b/cli/strategy/manual_commit_7_test.go
@@ -0,0 +1,657 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCondenseSession_V2DualWrite verifies that when checkpoints_v2 is enabled,
+// CondenseSession writes to both v1 (trace/checkpoints/v1) and v2 refs
+// (refs/trace/checkpoints/v2/main and refs/trace/checkpoints/v2/full/current).
+func TestCondenseSession_V2DualWrite(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644))
+ _, err = worktree.Add("main.go")
+ require.NoError(t, err)
+ commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ t.Chdir(dir)
+
+ // Enable checkpoints_v2 via settings
+ traceDir := filepath.Join(dir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-v2-dual-write"
+
+ // Create metadata directory with transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ secret := "q9Xv2Lm8Rt1Yp4Kd7Wz0Hs6Nc3Bf5Jg"
+ transcript := `{"type":"human","message":{"content":"hello secret: ` + secret + `"}}
+{"type":"assistant","message":{"content":"hi there"}}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
+
+ // SaveStep to create shadow branch
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ state.BaseCommit = commitHash.String()[:7]
+ state.AgentType = agent.AgentTypeClaudeCode
+
+ checkpointID := id.MustCheckpointID("dd11ee22ff33")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // v1 branch should exist (as before)
+ v1Ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err, "v1 metadata branch should exist")
+ require.NotEqual(t, plumbing.ZeroHash, v1Ref.Hash())
+
+ // v2 /main ref should exist
+ v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err, "v2 /main ref should exist")
+ require.NotEqual(t, plumbing.ZeroHash, v2MainRef.Hash())
+
+ // v2 /full/current ref should exist (transcript was non-empty)
+ v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
+ require.NoError(t, err, "v2 /full/current ref should exist")
+ require.NotEqual(t, plumbing.ZeroHash, v2FullRef.Hash())
+
+ // Verify /main has metadata and redacted compact transcript
+ v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
+ require.NoError(t, err)
+ v2MainTree, err := v2MainCommit.Tree()
+ require.NoError(t, err)
+
+ cpPath := checkpointID.Path()
+ mainCpTree, err := v2MainTree.Tree(cpPath)
+ require.NoError(t, err)
+
+ // Root metadata.json should exist
+ _, err = mainCpTree.File(paths.MetadataFileName)
+ require.NoError(t, err, "root metadata.json should exist on /main")
+
+ mainSessionTree, err := mainCpTree.Tree("0")
+ require.NoError(t, err)
+ compactFile, err := mainSessionTree.File(paths.CompactTranscriptFileName)
+ require.NoError(t, err, "transcript.jsonl should exist on /main")
+ compactContent, err := compactFile.Contents()
+ require.NoError(t, err)
+ require.NotContains(t, compactContent, secret, "compact transcript on /main must be redacted")
+
+ // Verify /full/current has transcript
+ v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
+ require.NoError(t, err)
+ v2FullTree, err := v2FullCommit.Tree()
+ require.NoError(t, err)
+
+ fullCpTree, err := v2FullTree.Tree(cpPath)
+ require.NoError(t, err)
+ fullSessionTree, err := fullCpTree.Tree("0")
+ require.NoError(t, err)
+ _, err = fullSessionTree.File(paths.V2RawTranscriptFileName)
+ require.NoError(t, err, "raw_transcript should exist on /full/current")
+}
+
+func TestCondenseSession_V2DualWrite_CopiesTaskMetadataToFullCurrent(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "main.go", "package main")
+ testutil.GitAdd(t, dir, "main.go")
+ testutil.GitCommit(t, dir, "Initial commit")
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+ commitHash := testutil.GetHeadHash(t, dir)
+
+ t.Chdir(dir)
+
+ traceDir := filepath.Join(dir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-v2-task-dual-write"
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"hello"}}
+{"type":"assistant","message":{"content":"hi there"}}
+`
+ transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
+
+ // Create shadow branch/session checkpoint data.
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ subagentTranscriptPath := filepath.Join(metadataDirAbs, "subagent.jsonl")
+ require.NoError(t, os.WriteFile(subagentTranscriptPath, []byte("{\"type\":\"event\",\"message\":\"done\"}\n"), 0o644))
+
+ err = s.SaveTaskStep(context.Background(), TaskStepContext{
+ SessionID: sessionID,
+ ToolUseID: "toolu_01TASK",
+ AgentID: "agent-01",
+ ModifiedFiles: []string{"main.go"},
+ TranscriptPath: transcriptPath,
+ SubagentTranscriptPath: subagentTranscriptPath,
+ CheckpointUUID: "uuid-task-001",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ SubagentType: "general",
+ TaskDescription: "Implement task",
+ AgentType: agent.AgentTypeClaudeCode,
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.TranscriptPath = transcriptPath
+ state.BaseCommit = commitHash[:12]
+ state.AgentType = agent.AgentTypeClaudeCode
+
+ checkpointID := id.MustCheckpointID("ab11cd22ef33")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
+ require.NoError(t, err, "v2 /full/current ref should exist")
+
+ v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
+ require.NoError(t, err)
+ v2FullTree, err := v2FullCommit.Tree()
+ require.NoError(t, err)
+
+ taskCheckpointPath := checkpointID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json"
+ _, err = v2FullTree.File(taskCheckpointPath)
+ require.NoError(t, err, "task checkpoint metadata should be copied to v2 /full/current")
+}
+
+// TestCondenseSession_V2CompactTranscriptStart verifies v2 /main writes
+// checkpoint_transcript_start from compact transcript offset, not full.jsonl offset.
+func TestCondenseSession_V2CompactTranscriptStart(t *testing.T) {
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "main.go", "package main")
+ testutil.GitAdd(t, dir, "main.go")
+ testutil.GitCommit(t, dir, "Initial commit")
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+ commitHash := testutil.GetHeadHash(t, dir)
+
+ t.Chdir(dir)
+
+ // Enable checkpoints_v2 via settings
+ traceDir := filepath.Join(dir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-v2-compact-start"
+
+ // Create metadata directory with transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"hello"}}
+{"type":"assistant","message":{"content":"hi there"}}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
+
+ // SaveStep to create shadow branch
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ state.BaseCommit = commitHash[:7]
+ state.AgentType = agent.AgentTypeClaudeCode
+
+ // First condensation starts at compact offset 0.
+ checkpointID := id.MustCheckpointID("cc11dd22ee33")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // v2 /main should have checkpoint_transcript_start = 0 for first checkpoint.
+ v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
+ require.NoError(t, err)
+ v2MainTree, err := v2MainCommit.Tree()
+ require.NoError(t, err)
+
+ cpPath := checkpointID.Path()
+ sessionTree, err := v2MainTree.Tree(cpPath + "/0")
+ require.NoError(t, err)
+ metadataFile, err := sessionTree.File(paths.MetadataFileName)
+ require.NoError(t, err)
+ metadataContent, err := metadataFile.Contents()
+ require.NoError(t, err)
+
+ var v2Metadata checkpoint.CommittedMetadata
+ require.NoError(t, json.Unmarshal([]byte(metadataContent), &v2Metadata))
+ require.Equal(t, 0, v2Metadata.CheckpointTranscriptStart,
+ "first checkpoint v2 metadata should have checkpoint_transcript_start=0")
+
+ // Read v1 metadata for comparison.
+ v1Ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err)
+ v1Commit, err := repo.CommitObject(v1Ref.Hash())
+ require.NoError(t, err)
+ v1Tree, err := v1Commit.Tree()
+ require.NoError(t, err)
+ v1SessionTree, err := v1Tree.Tree(cpPath + "/0")
+ require.NoError(t, err)
+ v1MetadataFile, err := v1SessionTree.File(paths.MetadataFileName)
+ require.NoError(t, err)
+ v1MetadataContent, err := v1MetadataFile.Contents()
+ require.NoError(t, err)
+
+ var v1Metadata checkpoint.CommittedMetadata
+ require.NoError(t, json.Unmarshal([]byte(v1MetadataContent), &v1Metadata))
+ require.Equal(t, 0, v1Metadata.CheckpointTranscriptStart,
+ "first checkpoint v1 metadata should also have checkpoint_transcript_start=0")
+
+ // Verify compact transcript lines were counted in the result
+ require.Positive(t, result.CompactTranscriptLines,
+ "CondenseResult should report compact transcript lines")
+
+ // Read compact transcript.jsonl from v2 /main for the first checkpoint.
+ compactFile1, err := sessionTree.File(paths.CompactTranscriptFileName)
+ require.NoError(t, err, "transcript.jsonl should exist on v2 /main")
+ compactContent1, err := compactFile1.Contents()
+ require.NoError(t, err)
+ firstCompactLines := bytes.Count([]byte(compactContent1), []byte{'\n'})
+ require.Positive(t, firstCompactLines, "first checkpoint compact transcript should have lines")
+
+ // --- Second condensation: add more transcript content ---
+ transcript2 := transcript + `{"type":"human","message":{"content":"next question"}}
+{"type":"assistant","message":{"content":"next answer"}}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript2), 0o644))
+
+ // Update state after first condensation (mimic what CondenseSessionByID does)
+ state.StepCount = 0
+ state.CheckpointTranscriptStart = result.TotalTranscriptLines
+ state.CompactTranscriptStart += result.CompactTranscriptLines
+
+ // SaveStep for second checkpoint
+ testutil.WriteFile(t, dir, "main.go", "package main\n// v2")
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state2, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state2.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ state2.BaseCommit = commitHash[:7]
+ state2.AgentType = agent.AgentTypeClaudeCode
+ state2.CheckpointTranscriptStart = state.CheckpointTranscriptStart
+ state2.CompactTranscriptStart = state.CompactTranscriptStart
+
+ checkpointID2 := id.MustCheckpointID("dd22ee33ff44")
+ result2, err := s.CondenseSession(context.Background(), repo, checkpointID2, state2, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result2)
+
+ // v2 /main metadata for second checkpoint should have compact start = firstCompactLines.
+ v2MainRef2, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ v2MainCommit2, err := repo.CommitObject(v2MainRef2.Hash())
+ require.NoError(t, err)
+ v2MainTree2, err := v2MainCommit2.Tree()
+ require.NoError(t, err)
+
+ cpPath2 := checkpointID2.Path()
+ sessionTree2, err := v2MainTree2.Tree(cpPath2 + "/0")
+ require.NoError(t, err)
+ metadataFile2, err := sessionTree2.File(paths.MetadataFileName)
+ require.NoError(t, err)
+ metadataContent2, err := metadataFile2.Contents()
+ require.NoError(t, err)
+
+ var v2Metadata2 checkpoint.CommittedMetadata
+ require.NoError(t, json.Unmarshal([]byte(metadataContent2), &v2Metadata2))
+ require.Equal(t, firstCompactLines, v2Metadata2.CheckpointTranscriptStart,
+ "second checkpoint v2 metadata should have checkpoint_transcript_start = first checkpoint's compact line count")
+
+ // The compact transcript.jsonl for checkpoint 2 should be CUMULATIVE:
+ // it should contain both checkpoint 1's and checkpoint 2's compact lines.
+ compactFile2, err := sessionTree2.File(paths.CompactTranscriptFileName)
+ require.NoError(t, err, "transcript.jsonl should exist for second checkpoint")
+ compactContent2, err := compactFile2.Contents()
+ require.NoError(t, err)
+ secondCompactTotalLines := bytes.Count([]byte(compactContent2), []byte{'\n'})
+ require.Greater(t, secondCompactTotalLines, firstCompactLines,
+ "second checkpoint compact transcript should include all prior content plus new content")
+
+ // The first checkpoint's content should be a prefix of the second checkpoint's content.
+ require.True(t, strings.HasPrefix(compactContent2, compactContent1),
+ "second checkpoint compact transcript should start with first checkpoint's content")
+}
+
+// TestCondenseSession_V2Disabled_NoV2Refs verifies that when checkpoints_v2 is
+// not enabled, CondenseSession only writes to v1 and does not create v2 refs.
+func TestCondenseSession_V2Disabled_NoV2Refs(t *testing.T) {
+ dir := t.TempDir()
+ repo, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+
+ worktree, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644))
+ _, err = worktree.Add("main.go")
+ require.NoError(t, err)
+ commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ t.Chdir(dir)
+
+ // No checkpoints_v2 setting — default is disabled
+ traceDir := filepath.Join(dir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ settingsJSON := `{"enabled": true, "strategy": "manual-commit"}`
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsJSON), 0o644))
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2025-01-15-test-v2-disabled"
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"hello"}}
+{"type":"assistant","message":{"content":"hi"}}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ state.BaseCommit = commitHash.String()[:7]
+
+ checkpointID := id.MustCheckpointID("ee22ff33aa44")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 0, result.CompactTranscriptLines, "v2-disabled condensation should not report compact transcript line deltas")
+
+ // v1 should exist
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err, "v1 metadata branch should exist")
+
+ // v2 refs should NOT exist
+ _, err = repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.Error(t, err, "v2 /main ref should not exist when v2 is disabled")
+
+ _, err = repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
+ require.Error(t, err, "v2 /full/current ref should not exist when v2 is disabled")
+}
+
+func TestCondenseSession_RedactionFailure_DropsTranscriptButWritesMetadata(t *testing.T) {
+ originalRedact := redactSessionJSONLBytes
+ redactSessionJSONLBytes = func([]byte) (redact.RedactedBytes, error) {
+ return redact.RedactedBytes{}, errors.New("forced redaction failure")
+ }
+ t.Cleanup(func() {
+ redactSessionJSONLBytes = originalRedact
+ })
+
+ dir := t.TempDir()
+ testutil.InitRepo(t, dir)
+ testutil.WriteFile(t, dir, "main.go", "package main")
+ testutil.GitAdd(t, dir, "main.go")
+ testutil.GitCommit(t, dir, "Initial commit")
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ headRef, err := repo.Head()
+ require.NoError(t, err)
+
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "2026-04-10-test-redaction-failure"
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := "{\"type\":\"human\",\"message\":{\"content\":\"hello\"}}\n"
+ require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"main.go"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ state.BaseCommit = headRef.Hash().String()[:7]
+ state.AgentType = agent.AgentTypeClaudeCode
+ state.FilesTouched = []string{"main.go"}
+
+ checkpointID := id.MustCheckpointID("aa11bb22cc33")
+ result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
+ require.NoError(t, err, "redaction failure should not abort condensation")
+ require.NotNil(t, result)
+
+ store, err := s.getCheckpointStore()
+ require.NoError(t, err)
+
+ committed, err := store.ListCommitted(context.Background())
+ require.NoError(t, err)
+ require.NotEmpty(t, committed)
+
+ found := false
+ for _, c := range committed {
+ if c.CheckpointID == checkpointID {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "checkpoint metadata should be written even when transcript redaction fails")
+
+ _, err = store.ReadLatestSessionContent(context.Background(), checkpointID)
+ require.ErrorIs(t, err, checkpoint.ErrNoTranscript, "transcript should be dropped when redaction fails")
+}
+
+func TestCommittedFilesExcludingMetadata(t *testing.T) {
+ t.Parallel()
+
+ input := map[string]struct{}{
+ "docs/blue.md": {},
+ "docs/red.md": {},
+ ".trace/settings.json": {},
+ ".trace/.gitignore": {},
+ ".claude/settings.json": {},
+ }
+
+ result := committedFilesExcludingMetadata(input)
+
+ // .trace/ files should be excluded, everything else kept
+ resultSet := make(map[string]struct{}, len(result))
+ for _, f := range result {
+ resultSet[f] = struct{}{}
+ }
+
+ require.Contains(t, resultSet, "docs/blue.md")
+ require.Contains(t, resultSet, "docs/red.md")
+ require.Contains(t, resultSet, ".claude/settings.json")
+ require.NotContains(t, resultSet, ".trace/settings.json", ".trace/ should be excluded")
+ require.NotContains(t, resultSet, ".trace/.gitignore", ".trace/ should be excluded")
+ require.Len(t, result, 3)
+}
+
+func TestMarshalPromptAttributionsIncludingPending_IncludesPending(t *testing.T) {
+ t.Parallel()
+
+ state := &SessionState{
+ PromptAttributions: []PromptAttribution{
+ {CheckpointNumber: 1, UserLinesAdded: 3},
+ },
+ PendingPromptAttribution: &PromptAttribution{
+ CheckpointNumber: 2, UserLinesAdded: 5,
+ },
+ }
+
+ raw := marshalPromptAttributionsIncludingPending(state)
+ require.NotNil(t, raw)
+
+ var result []PromptAttribution
+ require.NoError(t, json.Unmarshal(raw, &result))
+ require.Len(t, result, 2, "should include both committed and pending attributions")
+ require.Equal(t, 1, result[0].CheckpointNumber)
+ require.Equal(t, 3, result[0].UserLinesAdded)
+ require.Equal(t, 2, result[1].CheckpointNumber)
+ require.Equal(t, 5, result[1].UserLinesAdded)
+}
+
+func TestMarshalPromptAttributionsIncludingPending_NoPending(t *testing.T) {
+ t.Parallel()
+
+ state := &SessionState{
+ PromptAttributions: []PromptAttribution{
+ {CheckpointNumber: 1, UserLinesAdded: 3},
+ },
+ }
+
+ raw := marshalPromptAttributionsIncludingPending(state)
+ require.NotNil(t, raw)
+
+ var result []PromptAttribution
+ require.NoError(t, json.Unmarshal(raw, &result))
+ require.Len(t, result, 1)
+}
+
+func TestMarshalPromptAttributionsIncludingPending_Empty(t *testing.T) {
+ t.Parallel()
+
+ state := &SessionState{}
+ raw := marshalPromptAttributionsIncludingPending(state)
+ require.Nil(t, raw, "empty state should return nil")
+}
+
+func TestMarshalPromptAttributionsIncludingPending_OnlyPending(t *testing.T) {
+ t.Parallel()
+
+ state := &SessionState{
+ PendingPromptAttribution: &PromptAttribution{
+ CheckpointNumber: 1, UserLinesAdded: 7,
+ },
+ }
+
+ raw := marshalPromptAttributionsIncludingPending(state)
+ require.NotNil(t, raw, "pending-only should still produce output")
+
+ var result []PromptAttribution
+ require.NoError(t, json.Unmarshal(raw, &result))
+ require.Len(t, result, 1)
+ require.Equal(t, 7, result[0].UserLinesAdded)
+}
+
+func TestCommittedFilesExcludingMetadata_AllMetadata(t *testing.T) {
+ t.Parallel()
+
+ result := committedFilesExcludingMetadata(map[string]struct{}{
+ ".trace/settings.json": {},
+ ".trace/.gitignore": {},
+ })
+ require.Empty(t, result, "all metadata files should be excluded")
+}
diff --git a/cli/strategy/manual_commit_attribution_2_test.go b/cli/strategy/manual_commit_attribution_2_test.go
new file mode 100644
index 0000000..8b23223
--- /dev/null
+++ b/cli/strategy/manual_commit_attribution_2_test.go
@@ -0,0 +1,812 @@
+package strategy
+
+import (
+ "context"
+ "sort"
+ "testing"
+
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/go-git/go-git/v6/storage/memory"
+ "github.com/stretchr/testify/require"
+)
+
+// newTestTreeBuilder creates an independent in-memory storage and returns a
+// createTree helper that is safe to use from a single goroutine.
+//
+//nolint:errcheck // Test helper - errors would cause test failures anyway
+func newTestTreeBuilder() func(files map[string]string) *object.Tree {
+ storer := memory.NewStorage()
+ return func(files map[string]string) *object.Tree {
+ var entries []object.TreeEntry
+ for name, content := range files {
+ blob := storer.NewEncodedObject()
+ blob.SetType(plumbing.BlobObject)
+ writer, _ := blob.Writer()
+ _, _ = writer.Write([]byte(content))
+ _ = writer.Close()
+ hash, _ := storer.SetEncodedObject(blob)
+ entries = append(entries, object.TreeEntry{
+ Name: name,
+ Mode: 0o100644,
+ Hash: hash,
+ })
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name < entries[j].Name
+ })
+ tree := &object.Tree{Entries: entries}
+ treeObj := storer.NewEncodedObject()
+ _ = tree.Encode(treeObj)
+ treeHash, _ := storer.SetEncodedObject(treeObj)
+ decodedTree, _ := object.GetTree(storer, treeHash)
+ return decodedTree
+ }
+}
+
+// TestGetAllChangedFilesBetweenTreesSlow tests the go-git tree walk fallback
+// used by CondenseSessionByID (doctor command) when commit hashes are unavailable.
+func TestGetAllChangedFilesBetweenTreesSlow(t *testing.T) {
+ t.Parallel()
+
+ t.Run("both trees nil", func(t *testing.T) {
+ t.Parallel()
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result != nil {
+ t.Errorf("expected nil, got %v", result)
+ }
+ })
+
+ t.Run("tree1 nil (all files added)", func(t *testing.T) {
+ t.Parallel()
+ createTree := newTestTreeBuilder()
+ tree2 := createTree(map[string]string{
+ testFile1: "content1",
+ "file2.go": "content2",
+ })
+
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), nil, tree2)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ sort.Strings(result)
+
+ if len(result) != 2 {
+ t.Fatalf("expected 2 changed files, got %d: %v", len(result), result)
+ }
+ if result[0] != testFile1 || result[1] != "file2.go" {
+ t.Errorf("expected [file1.go, file2.go], got %v", result)
+ }
+ })
+
+ t.Run("tree2 nil (all files deleted)", func(t *testing.T) {
+ t.Parallel()
+ createTree := newTestTreeBuilder()
+ tree1 := createTree(map[string]string{
+ testFile1: "content1",
+ })
+
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(result) != 1 || result[0] != testFile1 {
+ t.Errorf("expected [file1.go], got %v", result)
+ }
+ })
+
+ t.Run("identical trees (no changes)", func(t *testing.T) {
+ t.Parallel()
+ createTree := newTestTreeBuilder()
+ tree1 := createTree(map[string]string{
+ testFile1: "same content",
+ "file2.go": "also same",
+ })
+ tree2 := createTree(map[string]string{
+ testFile1: "same content",
+ "file2.go": "also same",
+ })
+
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(result) != 0 {
+ t.Errorf("expected no changes, got %v", result)
+ }
+ })
+
+ t.Run("one file modified", func(t *testing.T) {
+ t.Parallel()
+ createTree := newTestTreeBuilder()
+ tree1 := createTree(map[string]string{
+ testFile1: "original",
+ "unchanged.go": "stays same",
+ })
+ tree2 := createTree(map[string]string{
+ testFile1: "modified",
+ "unchanged.go": "stays same",
+ })
+
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(result) != 1 || result[0] != testFile1 {
+ t.Errorf("expected [file1.go], got %v", result)
+ }
+ })
+
+ t.Run("file added and deleted", func(t *testing.T) {
+ t.Parallel()
+ createTree := newTestTreeBuilder()
+ tree1 := createTree(map[string]string{
+ "deleted.go": "will be removed",
+ "stays.go": "unchanged",
+ })
+ tree2 := createTree(map[string]string{
+ "added.go": "new file",
+ "stays.go": "unchanged",
+ })
+
+ result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ sort.Strings(result)
+
+ if len(result) != 2 {
+ t.Fatalf("expected 2 changed files, got %d: %v", len(result), result)
+ }
+ if result[0] != "added.go" || result[1] != "deleted.go" {
+ t.Errorf("expected [added.go, deleted.go], got %v", result)
+ }
+ })
+}
+
+// TestEstimateUserSelfModifications tests the LIFO heuristic for user self-modifications.
+func TestEstimateUserSelfModifications(t *testing.T) {
+ tests := []struct {
+ name string
+ accumulatedUserAdded map[string]int
+ postCheckpointRemoved map[string]int
+ expectedSelfModified int
+ }{
+ {
+ name: "no removals",
+ accumulatedUserAdded: map[string]int{"file.go": 5},
+ postCheckpointRemoved: map[string]int{},
+ expectedSelfModified: 0,
+ },
+ {
+ name: "removals less than user added",
+ accumulatedUserAdded: map[string]int{"file.go": 5},
+ postCheckpointRemoved: map[string]int{"file.go": 3},
+ expectedSelfModified: 3, // All 3 removals are self-modifications
+ },
+ {
+ name: "removals equal to user added",
+ accumulatedUserAdded: map[string]int{"file.go": 5},
+ postCheckpointRemoved: map[string]int{"file.go": 5},
+ expectedSelfModified: 5, // All 5 removals are self-modifications
+ },
+ {
+ name: "removals exceed user added",
+ accumulatedUserAdded: map[string]int{"file.go": 3},
+ postCheckpointRemoved: map[string]int{"file.go": 5},
+ expectedSelfModified: 3, // Only 3 are self-modifications, 2 must be agent lines
+ },
+ {
+ name: "no user additions to file",
+ accumulatedUserAdded: map[string]int{},
+ postCheckpointRemoved: map[string]int{"file.go": 5},
+ expectedSelfModified: 0, // All removals target agent lines
+ },
+ {
+ name: "multiple files",
+ accumulatedUserAdded: map[string]int{"a.go": 3, "b.go": 2},
+ postCheckpointRemoved: map[string]int{"a.go": 2, "b.go": 4},
+ expectedSelfModified: 4, // 2 from a.go + 2 from b.go (capped at user additions)
+ },
+ {
+ name: "removal from file user never touched",
+ accumulatedUserAdded: map[string]int{"a.go": 5},
+ postCheckpointRemoved: map[string]int{"b.go": 3},
+ expectedSelfModified: 0, // User never added to b.go, so all removals are agent lines
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := estimateUserSelfModifications(tt.accumulatedUserAdded, tt.postCheckpointRemoved)
+ if result != tt.expectedSelfModified {
+ t.Errorf("estimateUserSelfModifications() = %d, want %d", result, tt.expectedSelfModified)
+ }
+ })
+ }
+}
+
+// TestCalculateAttributionWithAccumulated_UserSelfModification tests the per-file tracking fix:
+// when a user modifies their own previously-added lines (not agent lines),
+// it should NOT reduce the agent's contribution.
+//
+// Bug scenario before fix:
+// 1. Agent adds 10 lines
+// 2. User adds 5 lines of their own (captured in PromptAttribution)
+// 3. User later removes 3 of their own lines and adds 3 different ones
+// 4. OLD: humanModified=3 was subtracted from agent lines (WRONG)
+// 5. NEW: humanModified=3 but userSelfModified=3, so agent lines unchanged (CORRECT)
+func TestCalculateAttributionWithAccumulated_UserSelfModification(t *testing.T) {
+ // Base: empty file
+ baseTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ })
+
+ // Shadow (checkpoint state): agent added 10 lines, user added 5 lines between checkpoints
+ // The shadow includes both because it's a snapshot of the worktree at checkpoint time
+ shadowTree := buildTestTree(t, map[string]string{
+ "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nuser3\nuser4\nuser5\n",
+ })
+
+ // Head (commit state): user removed 3 of their own lines and added 3 different ones
+ // Agent lines are unchanged
+ headTree := buildTestTree(t, map[string]string{
+ "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nnew_user1\nnew_user2\nnew_user3\n",
+ })
+
+ filesTouched := []string{"main.go"}
+
+ // PromptAttribution captured that user added 5 lines between checkpoints
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 2,
+ UserLinesAdded: 5,
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{"main.go": 5}, // KEY: per-file tracking
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result, "expected non-nil result")
+
+ // Expected calculation with per-file tracking:
+ // - base → shadow: 15 lines added (10 agent + 5 user)
+ // - accumulatedUserAdded: 5 (from PromptAttribution)
+ // - totalAgentAdded: 15 - 5 = 10
+ // - shadow → head: +3 lines added, -3 lines removed (user modification)
+ // - totalUserAdded: 5 + 3 = 8
+ // - totalUserRemoved: 3
+ // - totalHumanModified: min(8, 3) = 3
+ // - userSelfModified: min(3 removed from main.go, 5 user added to main.go) = 3
+ // - humanModifiedAgent: 3 - 3 = 0 (no agent lines were modified!)
+ // - agentLinesInCommit: 10 - 0 - 0 = 10 (CORRECT: agent lines unchanged)
+ // - TotalCommitted = 10 + 5 = 15 (legacy net-additions metric)
+ // - TotalLinesChanged = 10 agent + 5 added + 3 modified = 18
+ // - Agent percentage: 10/18 = 55.6%
+
+ t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, total=%d, percentage=%.1f%%",
+ result.AgentLines, result.HumanAdded, result.HumanModified, result.TotalCommitted, result.AgentPercentage)
+
+ if result.AgentLines != 10 {
+ t.Errorf("AgentLines = %d, want 10 (agent lines should NOT be reduced by user self-modifications)", result.AgentLines)
+ }
+ if result.HumanAdded != 5 {
+ t.Errorf("HumanAdded = %d, want 5 (8 total - 3 modifications)", result.HumanAdded)
+ }
+ if result.HumanModified != 3 {
+ t.Errorf("HumanModified = %d, want 3 (total modifications for reporting)", result.HumanModified)
+ }
+ if result.TotalCommitted != 15 {
+ t.Errorf("TotalCommitted = %d, want 15", result.TotalCommitted)
+ }
+ if result.TotalLinesChanged != 18 {
+ t.Errorf("TotalLinesChanged = %d, want 18", result.TotalLinesChanged)
+ }
+ if result.AgentPercentage < 55.5 || result.AgentPercentage > 55.7 {
+ t.Errorf("AgentPercentage = %.1f%%, want ~55.6%%", result.AgentPercentage)
+ }
+}
+
+// TestCalculateAttributionWithAccumulated_MixedModifications tests the case where
+// user modifies both their own lines AND agent lines.
+func TestCalculateAttributionWithAccumulated_MixedModifications(t *testing.T) {
+ // Base: empty file
+ baseTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ })
+
+ // Shadow: agent added 10 lines, user added 3 lines
+ shadowTree := buildTestTree(t, map[string]string{
+ "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nuser3\n",
+ })
+
+ // Head: user removed 5 lines (3 own + 2 agent) and added 5 new lines
+ // Net effect: user modified 5 lines total
+ headTree := buildTestTree(t, map[string]string{
+ "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nnew1\nnew2\nnew3\nnew4\nnew5\n",
+ })
+
+ filesTouched := []string{"main.go"}
+
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 2,
+ UserLinesAdded: 3,
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{"main.go": 3},
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result, "expected non-nil result")
+
+ // Expected calculation:
+ // - base → shadow: 13 lines added (10 agent + 3 user)
+ // - accumulatedUserAdded: 3
+ // - totalAgentAdded: 13 - 3 = 10
+ // - shadow → head: +5 added, -5 removed
+ // - totalUserAdded: 3 + 5 = 8
+ // - totalUserRemoved: 5
+ // - totalHumanModified: min(8, 5) = 5
+ // - userSelfModified: min(5 removed, 3 user added) = 3 (user exhausted their pool)
+ // - humanModifiedAgent: 5 - 3 = 2 (2 modifications targeted agent lines)
+ // - agentLinesInCommit: 10 - 0 - 2 = 8 (reduced by modifications to agent lines only)
+ // - pureUserAdded: 8 - 5 = 3
+ // - TotalCommitted = 10 + 3 = 13 (legacy net-additions metric)
+ // - TotalLinesChanged = 8 agent + 3 added + 5 modified = 16
+ // - Agent percentage: 8/16 = 50%
+
+ t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, total=%d, percentage=%.1f%%",
+ result.AgentLines, result.HumanAdded, result.HumanModified, result.TotalCommitted, result.AgentPercentage)
+
+ if result.AgentLines != 8 {
+ t.Errorf("AgentLines = %d, want 8 (10 - 2 modifications to agent lines)", result.AgentLines)
+ }
+ if result.HumanModified != 5 {
+ t.Errorf("HumanModified = %d, want 5", result.HumanModified)
+ }
+ if result.TotalCommitted != 13 {
+ t.Errorf("TotalCommitted = %d, want 13", result.TotalCommitted)
+ }
+ if result.TotalLinesChanged != 16 {
+ t.Errorf("TotalLinesChanged = %d, want 16", result.TotalLinesChanged)
+ }
+ if result.AgentPercentage < 49.9 || result.AgentPercentage > 50.1 {
+ t.Errorf("AgentPercentage = %.1f%%, want 50.0%%", result.AgentPercentage)
+ }
+}
+
+// TestCalculateAttributionWithAccumulated_UncommittedWorktreeFiles tests the bug where
+// files in the worktree but NOT in the commit inflate the attribution calculation.
+//
+// Bug scenario:
+// 1. Agent creates docs/example.md (17 lines)
+// 2. .claude/settings.json (84 lines) exists in worktree from agent setup
+// 3. calculatePromptAttributionAtStart captures .claude/settings.json as user change
+// 4. User commits only docs/example.md (git add docs/ && git commit)
+// 5. BUG: accumulatedUserAdded=84 inflates totalUserAdded and totalCommitted
+// 6. Result: agentPercentage = 17/101 = 16.8% instead of 100%
+func TestCalculateAttributionWithAccumulated_UncommittedWorktreeFiles(t *testing.T) {
+ t.Parallel()
+
+ // Base: empty tree (initial --allow-empty commit)
+ baseTree := buildTestTree(t, nil)
+
+ // Shadow (agent checkpoint): agent created example.md
+ agentContent := "# Software Testing\n\nSoftware testing is a critical part of the development process.\n\n## Types of Testing\n\n- Unit testing\n- Integration testing\n- End-to-end testing\n\n## Best Practices\n\nWrite tests early.\nAutomate where possible.\nTest edge cases.\nReview test coverage.\n"
+ shadowTree := buildTestTree(t, map[string]string{
+ "example.md": agentContent,
+ })
+
+ // Head (committed): same file, only example.md was committed
+ // .claude/settings.json is NOT in the head tree (not committed)
+ headTree := buildTestTree(t, map[string]string{
+ "example.md": agentContent,
+ })
+
+ filesTouched := []string{"example.md"}
+
+ // PromptAttribution captured .claude/settings.json (84 lines) as user change
+ // at prompt start, because it was in the worktree but not in the base tree.
+ // This is the root cause of the bug: these 84 lines are never committed.
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 1,
+ UserLinesAdded: 84,
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{".claude/settings.json": 84},
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result, "expected non-nil result")
+
+ agentLines := countLinesStr(agentContent)
+ t.Logf("Agent content has %d lines", agentLines)
+ t.Logf("Attribution: agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
+ result.AgentLines, result.HumanAdded, result.TotalCommitted, result.AgentPercentage)
+
+ // Expected: agent created 100% of committed content
+ // .claude/settings.json should NOT affect attribution since it was never committed
+ if result.AgentLines != agentLines {
+ t.Errorf("AgentLines = %d, want %d", result.AgentLines, agentLines)
+ }
+ if result.HumanAdded != 0 {
+ t.Errorf("HumanAdded = %d, want 0 (.claude/settings.json was never committed)", result.HumanAdded)
+ }
+ if result.TotalCommitted != agentLines {
+ t.Errorf("TotalCommitted = %d, want %d (only agent-created file was committed)", result.TotalCommitted, agentLines)
+ }
+ if result.AgentPercentage != 100.0 {
+ t.Errorf("AgentPercentage = %.1f%%, want 100.0%% (agent created all committed content)", result.AgentPercentage)
+ }
+}
+
+// TestCalculatePromptAttribution_PopulatesPerFile verifies that CalculatePromptAttribution
+// correctly populates the UserAddedPerFile map.
+func TestCalculatePromptAttribution_PopulatesPerFile(t *testing.T) {
+ // Base: two files
+ baseTree := buildTestTree(t, map[string]string{
+ "a.go": "line1\n",
+ "b.go": "line1\n",
+ })
+
+ // Last checkpoint: agent added lines to both files
+ lastCheckpointTree := buildTestTree(t, map[string]string{
+ "a.go": "line1\nagent1\n",
+ "b.go": "line1\nagent1\nagent2\n",
+ })
+
+ // Current worktree: user added lines to both files
+ worktreeFiles := map[string]string{
+ "a.go": "line1\nagent1\nuser1\nuser2\nuser3\n", // +3 user lines
+ "b.go": "line1\nagent1\nagent2\nuser1\n", // +1 user line
+ }
+
+ result := CalculatePromptAttribution(baseTree, lastCheckpointTree, worktreeFiles, 2)
+
+ if result.UserLinesAdded != 4 {
+ t.Errorf("UserLinesAdded = %d, want 4 (3 + 1)", result.UserLinesAdded)
+ }
+
+ if result.UserAddedPerFile == nil {
+ t.Fatal("UserAddedPerFile should not be nil")
+ }
+
+ if result.UserAddedPerFile["a.go"] != 3 {
+ t.Errorf("UserAddedPerFile[a.go] = %d, want 3", result.UserAddedPerFile["a.go"])
+ }
+ if result.UserAddedPerFile["b.go"] != 1 {
+ t.Errorf("UserAddedPerFile[b.go] = %d, want 1", result.UserAddedPerFile["b.go"])
+ }
+}
+
+// TestCalculateAttributionWithAccumulated_PreSessionDirtOnAgentFiles verifies that
+// pre-session worktree dirt (captured in PA1 / checkpoint 1) on files the agent later
+// touches does NOT get counted as human contributions.
+//
+// Scenario: hooks.go has 3 pre-session dirty lines when session starts.
+// Agent also modifies hooks.go (adds 5 more lines). Shadow captures all 8 new lines.
+// At commit time, the 3 pre-session lines should be excluded from human count.
+func TestCalculateAttributionWithAccumulated_PreSessionDirtOnAgentFiles(t *testing.T) {
+ t.Parallel()
+
+ // Base: hooks.go has 3 lines
+ baseTree := buildTestTree(t, map[string]string{
+ "hooks.go": "package strategy\n\nfunc warn() {}\n",
+ })
+
+ // Shadow captures base (3 lines) + pre-session dirt (3 new lines) + agent work (5 new lines)
+ // = 11 total lines, 8 added relative to base
+ shadowContent := "package strategy\n\n// pre1\n// pre2\n// pre3\nfunc agentA() {}\nfunc agentB() {}\nfunc agentC() {}\nfunc agentD() {}\nfunc agentE() {}\nfunc warn() {}\n"
+ shadowTree := buildTestTree(t, map[string]string{
+ "hooks.go": shadowContent,
+ })
+
+ // Head = shadow (user didn't edit after agent)
+ headTree := shadowTree
+
+ filesTouched := []string{"hooks.go"}
+
+ // PA1 captured the 3 pre-session dirty lines at session start
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 1,
+ UserLinesAdded: 3,
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{"hooks.go": 3},
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result)
+
+ // base→shadow adds 8 lines. PA1 says 3 are pre-session.
+ // totalAgentAdded = 8 - 3 = 5 (correct agent subtraction).
+ // Pre-session 3 lines should NOT appear in HumanAdded.
+ require.Equal(t, 5, result.AgentLines, "agent should get credit for 5 lines")
+ require.Equal(t, 0, result.HumanAdded, "pre-session dirt should not count as human")
+ require.Equal(t, 5, result.TotalCommitted, "total should be agent-only")
+ require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
+}
+
+// TestCalculateAttributionWithAccumulated_PreSessionConfigFiles verifies that
+// non-agent files dirty at session start (e.g., CLI config files from `trace enable`)
+// do NOT get counted as human contributions.
+//
+// Uses flat file names because buildTestTree doesn't support nested paths.
+// The attribution code only checks filesTouched membership and UserAddedPerFile keys,
+// so flat names are equivalent for testing.
+func TestCalculateAttributionWithAccumulated_PreSessionConfigFiles(t *testing.T) {
+ t.Parallel()
+
+ // Base: empty repo
+ baseTree := buildTestTree(t, map[string]string{
+ "empty": "",
+ })
+
+ // Shadow: agent created hello.py (5 lines). Config file also present (10 lines).
+ shadowTree := buildTestTree(t, map[string]string{
+ "empty": "",
+ "hello.py": "line1\nline2\nline3\nline4\nline5\n",
+ "config.json": "k1\nk2\nk3\nk4\nk5\nk6\nk7\nk8\nk9\nk10\n",
+ })
+
+ // Head = shadow (user didn't edit)
+ headTree := shadowTree
+
+ filesTouched := []string{"hello.py"}
+
+ // PA1 captured the config file at session start (pre-session dirty)
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 1,
+ UserLinesAdded: 10,
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{"config.json": 10},
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result)
+
+ // Agent created hello.py (5 lines). Config file is pre-session baseline — excluded.
+ require.Equal(t, 5, result.AgentLines, "agent should get 5 lines for hello.py")
+ require.Equal(t, 0, result.HumanAdded, "pre-session config should not count as human")
+ require.Equal(t, 5, result.TotalCommitted, "total should be agent-only")
+ require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
+}
+
+// TestCalculateAttributionWithAccumulated_DuringSessionHumanEdits verifies that
+// human edits made DURING the session (captured by PA2+) are still correctly
+// counted as human contributions after the baseline fix.
+//
+// This is a correctness guard — the fix must not break this.
+func TestCalculateAttributionWithAccumulated_DuringSessionHumanEdits(t *testing.T) {
+ t.Parallel()
+
+ baseTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ })
+
+ // Shadow: 12 lines total — 10 agent + 2 user (added between turns)
+ shadowTree := buildTestTree(t, map[string]string{
+ "main.go": "a1\na2\na3\na4\na5\na6\na7\na8\nu1\nu2\na9\na10\n",
+ })
+
+ headTree := shadowTree
+
+ filesTouched := []string{"main.go"}
+
+ promptAttributions := []PromptAttribution{
+ {
+ CheckpointNumber: 1,
+ UserLinesAdded: 0, // Clean worktree at session start
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{},
+ },
+ {
+ CheckpointNumber: 2,
+ UserLinesAdded: 2, // User added 2 lines between turn 1 and 2
+ UserLinesRemoved: 0,
+ UserAddedPerFile: map[string]int{"main.go": 2},
+ },
+ }
+
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, result)
+
+ // 12 total lines in shadow. PA2 says user added 2. Agent = 12 - 2 = 10.
+ require.Equal(t, 10, result.AgentLines, "agent should get 10 lines")
+ require.Equal(t, 2, result.HumanAdded, "user's 2 lines from PA2 should count")
+ require.Equal(t, 12, result.TotalCommitted)
+ require.InDelta(t, 83.3, result.AgentPercentage, 0.1)
+}
+
+// TestCalculateAttributionWithAccumulated_EmptyPA verifies that sessions with
+// no prompt attributions (old CLI versions, edge cases) still work correctly.
+func TestCalculateAttributionWithAccumulated_EmptyPA(t *testing.T) {
+ t.Parallel()
+
+ baseTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ })
+
+ shadowTree := buildTestTree(t, map[string]string{
+ "main.go": "line1\nline2\nline3\n",
+ })
+
+ headTree := shadowTree
+ filesTouched := []string{"main.go"}
+
+ // No prompt attributions at all (old session or edge case)
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched,
+ })
+
+ require.NotNil(t, result)
+ require.Equal(t, 3, result.AgentLines)
+ require.Equal(t, 0, result.HumanAdded)
+ require.InDelta(t, 100.0, result.AgentPercentage, 0.1)
+}
+
+// TestCalculateAttributionWithAccumulated_ParentTreeForNonAgentLines verifies that
+// non-agent file line counting uses parentTree (not baseTree) when provided.
+// This prevents inflation in multi-commit sessions where a non-agent file was
+// modified in an intermediate commit AND the current commit.
+//
+// Scenario (multi-commit session):
+// - Session starts at commit A: readme.md has 2 lines
+// - Commit B: user adds 5 lines to readme.md (intermediate commit)
+// - Commit C (current): agent modifies main.go, user adds 3 more lines to readme.md
+//
+// Without parentTree: diffLines(baseTree=A, headTree=C) counts ALL 8 lines → inflated
+// With parentTree: diffLines(parentTree=B, headTree=C) counts only 3 lines → correct
+func TestCalculateAttributionWithAccumulated_ParentTreeForNonAgentLines(t *testing.T) {
+ t.Parallel()
+
+ // baseTree = commit A: readme.md has 2 lines, main.go is empty
+ baseTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ "readme.md": "line1\nline2\n",
+ })
+
+ // parentTree = commit B: readme.md grew to 7 lines (user added 5 in intermediate commit)
+ parentTree := buildTestTree(t, map[string]string{
+ "main.go": "",
+ "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\n",
+ })
+
+ // shadowTree: agent added 4 lines to main.go (checkpoint state)
+ shadowTree := buildTestTree(t, map[string]string{
+ "main.go": "func a() {}\nfunc b() {}\nfunc c() {}\nfunc d() {}\n",
+ "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\n",
+ })
+
+ // headTree = commit C: agent's main.go + user added 3 more lines to readme.md
+ headTree := buildTestTree(t, map[string]string{
+ "main.go": "func a() {}\nfunc b() {}\nfunc c() {}\nfunc d() {}\n",
+ "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\nnew1\nnew2\nnew3\n",
+ })
+
+ filesTouched := []string{"main.go"}
+
+ // No prompt attributions (clean worktree at session start)
+ promptAttributions := []PromptAttribution{}
+
+ // WITH parentTree: should only count 3 new readme.md lines (parent→head)
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ ParentTree: parentTree,
+ })
+
+ require.NotNil(t, result)
+ require.Equal(t, 4, result.AgentLines, "agent added 4 lines to main.go")
+ require.Equal(t, 3, result.HumanAdded, "only 3 lines from THIS commit, not all 8 since session start")
+ require.Equal(t, 7, result.TotalCommitted, "4 agent + 3 human")
+ require.InDelta(t, 57.1, result.AgentPercentage, 0.2, "4/7 = 57.1%")
+
+ // WITHOUT parentTree (nil): would count all 8 lines since session start — verify the bug
+ resultNoPT := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, resultNoPT)
+ // Without parentTree, falls back to baseTree: counts 8 lines (all since session start)
+ require.Equal(t, 8, resultNoPT.HumanAdded, "without parentTree, all 8 lines counted (inflated)")
+}
+
+// TestCalculateAttributionWithAccumulated_MultiSessionCrossExclusion verifies that
+// files touched by OTHER agent sessions in the same commit are not counted as human work.
+//
+// Scenario: two sessions create files, then both are committed together.
+// - Session 0 created blue.md (3 lines)
+// - Session 1 created red.md (3 lines)
+//
+// When calculating Session 0's attribution, red.md should be excluded via AllAgentFiles
+// (the union of all sessions' FilesTouched), not counted as human_added.
+func TestCalculateAttributionWithAccumulated_MultiSessionCrossExclusion(t *testing.T) {
+ t.Parallel()
+
+ baseTree := buildTestTree(t, nil)
+
+ // Shadow: Session 0 created blue.md
+ shadowTree := buildTestTree(t, map[string]string{
+ "blue.md": "line1\nline2\nline3\n",
+ })
+
+ // Head: commit contains both blue.md and red.md (from two sessions)
+ headTree := buildTestTree(t, map[string]string{
+ "blue.md": "line1\nline2\nline3\n",
+ "red.md": "line1\nline2\nline3\n",
+ })
+
+ // Session 0 only touched blue.md
+ filesTouched := []string{"blue.md"}
+
+ promptAttributions := []PromptAttribution{
+ {CheckpointNumber: 1, UserAddedPerFile: map[string]int{}},
+ }
+
+ // AllAgentFiles = union of ALL sessions' FilesTouched
+ allAgentFiles := map[string]struct{}{
+ "blue.md": {},
+ "red.md": {}, // From Session 1
+ }
+
+ // WITH AllAgentFiles: red.md excluded from human count
+ result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ AllAgentFiles: allAgentFiles,
+ })
+
+ require.NotNil(t, result)
+ require.Equal(t, 3, result.AgentLines, "agent should get 3 lines for blue.md")
+ require.Equal(t, 0, result.HumanAdded, "red.md should NOT count as human (other agent session)")
+ require.Equal(t, 3, result.TotalCommitted, "total should be agent-only for this session's scope")
+ require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
+
+ // WITHOUT AllAgentFiles: red.md incorrectly counted as human (the bug)
+ resultNoExcl := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
+ BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
+ FilesTouched: filesTouched, PromptAttributions: promptAttributions,
+ })
+
+ require.NotNil(t, resultNoExcl)
+ require.Equal(t, 3, resultNoExcl.HumanAdded, "without AllAgentFiles, red.md counted as human (inflated)")
+ require.Equal(t, 6, resultNoExcl.TotalCommitted, "inflated total includes red.md as human")
+}
diff --git a/cli/strategy/manual_commit_attribution_3_test.go b/cli/strategy/manual_commit_attribution_3_test.go
new file mode 100644
index 0000000..cbaeabe
--- /dev/null
+++ b/cli/strategy/manual_commit_attribution_3_test.go
@@ -0,0 +1,75 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestWarnIfAttributionDiverged_MultipleDivergentSessions_FlagsAllOnce verifies that
+// when multiple sessions have attribution divergence, the stderr warning is printed
+// exactly once per call and the DivergenceNoticeShown flag is persisted on every
+// divergent session — not just the first. The previous implementation broke out of the
+// loop after flagging the first session, which caused the "show-once" warning to
+// re-trigger on later prepare-commit-msg invocations for each additional divergent
+// session.
+func TestWarnIfAttributionDiverged_MultipleDivergentSessions_FlagsAllOnce(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ s := &ManualCommitStrategy{}
+
+ now := time.Now()
+ sessions := []*SessionState{
+ {
+ SessionID: "diverged-a",
+ BaseCommit: strings.Repeat("a", 40),
+ AttributionBaseCommit: strings.Repeat("b", 40),
+ StartedAt: now,
+ },
+ {
+ SessionID: "diverged-b",
+ BaseCommit: strings.Repeat("c", 40),
+ AttributionBaseCommit: strings.Repeat("d", 40),
+ StartedAt: now,
+ },
+ }
+ for _, sess := range sessions {
+ require.NoError(t, s.saveSessionState(context.Background(), sess))
+ }
+
+ var buf bytes.Buffer
+ oldWriter := stderrWriter
+ stderrWriter = &buf
+ defer func() { stderrWriter = oldWriter }()
+
+ s.warnIfAttributionDiverged(context.Background(), sessions)
+
+ require.Equal(t, 1, strings.Count(buf.String(), "trace: session attribution diverged"),
+ "warning must print exactly once even with multiple divergent sessions, got:\n%s", buf.String())
+
+ for _, sess := range sessions {
+ require.True(t, sess.DivergenceNoticeShown,
+ "DivergenceNoticeShown must be set on every divergent session (session %s)",
+ sess.SessionID)
+
+ // The flag must also be persisted to disk — the whole point of "show-once"
+ // is cross-invocation suppression. An in-memory-only mutation would let the
+ // warning re-fire on the next prepare-commit-msg.
+ reloaded, err := s.loadSessionState(context.Background(), sess.SessionID)
+ require.NoError(t, err)
+ require.NotNil(t, reloaded, "session %s should be persisted", sess.SessionID)
+ require.True(t, reloaded.DivergenceNoticeShown,
+ "DivergenceNoticeShown must be persisted to disk for session %s", sess.SessionID)
+ }
+
+ // Second call on the same slice must print nothing — flags are already set.
+ buf.Reset()
+ s.warnIfAttributionDiverged(context.Background(), sessions)
+ require.Empty(t, buf.String(),
+ "warning must stay silent on subsequent calls once every divergent session has been flagged")
+}
diff --git a/cli/strategy/manual_commit_attribution_test.go b/cli/strategy/manual_commit_attribution_test.go
index 73cd1fa..df71d80 100644
--- a/cli/strategy/manual_commit_attribution_test.go
+++ b/cli/strategy/manual_commit_attribution_test.go
@@ -1,12 +1,9 @@
package strategy
import (
- "bytes"
"context"
"sort"
- "strings"
"testing"
- "time"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
@@ -808,867 +805,3 @@ func TestCalculateAttributionWithAccumulated_UserEditsNonAgentFile(t *testing.T)
t.Errorf("AgentPercentage = %.1f%%, want ~60.0%%", result.AgentPercentage)
}
}
-
-// newTestTreeBuilder creates an independent in-memory storage and returns a
-// createTree helper that is safe to use from a single goroutine.
-//
-//nolint:errcheck // Test helper - errors would cause test failures anyway
-func newTestTreeBuilder() func(files map[string]string) *object.Tree {
- storer := memory.NewStorage()
- return func(files map[string]string) *object.Tree {
- var entries []object.TreeEntry
- for name, content := range files {
- blob := storer.NewEncodedObject()
- blob.SetType(plumbing.BlobObject)
- writer, _ := blob.Writer()
- _, _ = writer.Write([]byte(content))
- _ = writer.Close()
- hash, _ := storer.SetEncodedObject(blob)
- entries = append(entries, object.TreeEntry{
- Name: name,
- Mode: 0o100644,
- Hash: hash,
- })
- }
- sort.Slice(entries, func(i, j int) bool {
- return entries[i].Name < entries[j].Name
- })
- tree := &object.Tree{Entries: entries}
- treeObj := storer.NewEncodedObject()
- _ = tree.Encode(treeObj)
- treeHash, _ := storer.SetEncodedObject(treeObj)
- decodedTree, _ := object.GetTree(storer, treeHash)
- return decodedTree
- }
-}
-
-// TestGetAllChangedFilesBetweenTreesSlow tests the go-git tree walk fallback
-// used by CondenseSessionByID (doctor command) when commit hashes are unavailable.
-func TestGetAllChangedFilesBetweenTreesSlow(t *testing.T) {
- t.Parallel()
-
- t.Run("both trees nil", func(t *testing.T) {
- t.Parallel()
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), nil, nil)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if result != nil {
- t.Errorf("expected nil, got %v", result)
- }
- })
-
- t.Run("tree1 nil (all files added)", func(t *testing.T) {
- t.Parallel()
- createTree := newTestTreeBuilder()
- tree2 := createTree(map[string]string{
- testFile1: "content1",
- "file2.go": "content2",
- })
-
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), nil, tree2)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- sort.Strings(result)
-
- if len(result) != 2 {
- t.Fatalf("expected 2 changed files, got %d: %v", len(result), result)
- }
- if result[0] != testFile1 || result[1] != "file2.go" {
- t.Errorf("expected [file1.go, file2.go], got %v", result)
- }
- })
-
- t.Run("tree2 nil (all files deleted)", func(t *testing.T) {
- t.Parallel()
- createTree := newTestTreeBuilder()
- tree1 := createTree(map[string]string{
- testFile1: "content1",
- })
-
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, nil)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(result) != 1 || result[0] != testFile1 {
- t.Errorf("expected [file1.go], got %v", result)
- }
- })
-
- t.Run("identical trees (no changes)", func(t *testing.T) {
- t.Parallel()
- createTree := newTestTreeBuilder()
- tree1 := createTree(map[string]string{
- testFile1: "same content",
- "file2.go": "also same",
- })
- tree2 := createTree(map[string]string{
- testFile1: "same content",
- "file2.go": "also same",
- })
-
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(result) != 0 {
- t.Errorf("expected no changes, got %v", result)
- }
- })
-
- t.Run("one file modified", func(t *testing.T) {
- t.Parallel()
- createTree := newTestTreeBuilder()
- tree1 := createTree(map[string]string{
- testFile1: "original",
- "unchanged.go": "stays same",
- })
- tree2 := createTree(map[string]string{
- testFile1: "modified",
- "unchanged.go": "stays same",
- })
-
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(result) != 1 || result[0] != testFile1 {
- t.Errorf("expected [file1.go], got %v", result)
- }
- })
-
- t.Run("file added and deleted", func(t *testing.T) {
- t.Parallel()
- createTree := newTestTreeBuilder()
- tree1 := createTree(map[string]string{
- "deleted.go": "will be removed",
- "stays.go": "unchanged",
- })
- tree2 := createTree(map[string]string{
- "added.go": "new file",
- "stays.go": "unchanged",
- })
-
- result, err := getAllChangedFilesBetweenTreesSlow(context.Background(), tree1, tree2)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- sort.Strings(result)
-
- if len(result) != 2 {
- t.Fatalf("expected 2 changed files, got %d: %v", len(result), result)
- }
- if result[0] != "added.go" || result[1] != "deleted.go" {
- t.Errorf("expected [added.go, deleted.go], got %v", result)
- }
- })
-}
-
-// TestEstimateUserSelfModifications tests the LIFO heuristic for user self-modifications.
-func TestEstimateUserSelfModifications(t *testing.T) {
- tests := []struct {
- name string
- accumulatedUserAdded map[string]int
- postCheckpointRemoved map[string]int
- expectedSelfModified int
- }{
- {
- name: "no removals",
- accumulatedUserAdded: map[string]int{"file.go": 5},
- postCheckpointRemoved: map[string]int{},
- expectedSelfModified: 0,
- },
- {
- name: "removals less than user added",
- accumulatedUserAdded: map[string]int{"file.go": 5},
- postCheckpointRemoved: map[string]int{"file.go": 3},
- expectedSelfModified: 3, // All 3 removals are self-modifications
- },
- {
- name: "removals equal to user added",
- accumulatedUserAdded: map[string]int{"file.go": 5},
- postCheckpointRemoved: map[string]int{"file.go": 5},
- expectedSelfModified: 5, // All 5 removals are self-modifications
- },
- {
- name: "removals exceed user added",
- accumulatedUserAdded: map[string]int{"file.go": 3},
- postCheckpointRemoved: map[string]int{"file.go": 5},
- expectedSelfModified: 3, // Only 3 are self-modifications, 2 must be agent lines
- },
- {
- name: "no user additions to file",
- accumulatedUserAdded: map[string]int{},
- postCheckpointRemoved: map[string]int{"file.go": 5},
- expectedSelfModified: 0, // All removals target agent lines
- },
- {
- name: "multiple files",
- accumulatedUserAdded: map[string]int{"a.go": 3, "b.go": 2},
- postCheckpointRemoved: map[string]int{"a.go": 2, "b.go": 4},
- expectedSelfModified: 4, // 2 from a.go + 2 from b.go (capped at user additions)
- },
- {
- name: "removal from file user never touched",
- accumulatedUserAdded: map[string]int{"a.go": 5},
- postCheckpointRemoved: map[string]int{"b.go": 3},
- expectedSelfModified: 0, // User never added to b.go, so all removals are agent lines
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := estimateUserSelfModifications(tt.accumulatedUserAdded, tt.postCheckpointRemoved)
- if result != tt.expectedSelfModified {
- t.Errorf("estimateUserSelfModifications() = %d, want %d", result, tt.expectedSelfModified)
- }
- })
- }
-}
-
-// TestCalculateAttributionWithAccumulated_UserSelfModification tests the per-file tracking fix:
-// when a user modifies their own previously-added lines (not agent lines),
-// it should NOT reduce the agent's contribution.
-//
-// Bug scenario before fix:
-// 1. Agent adds 10 lines
-// 2. User adds 5 lines of their own (captured in PromptAttribution)
-// 3. User later removes 3 of their own lines and adds 3 different ones
-// 4. OLD: humanModified=3 was subtracted from agent lines (WRONG)
-// 5. NEW: humanModified=3 but userSelfModified=3, so agent lines unchanged (CORRECT)
-func TestCalculateAttributionWithAccumulated_UserSelfModification(t *testing.T) {
- // Base: empty file
- baseTree := buildTestTree(t, map[string]string{
- "main.go": "",
- })
-
- // Shadow (checkpoint state): agent added 10 lines, user added 5 lines between checkpoints
- // The shadow includes both because it's a snapshot of the worktree at checkpoint time
- shadowTree := buildTestTree(t, map[string]string{
- "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nuser3\nuser4\nuser5\n",
- })
-
- // Head (commit state): user removed 3 of their own lines and added 3 different ones
- // Agent lines are unchanged
- headTree := buildTestTree(t, map[string]string{
- "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nnew_user1\nnew_user2\nnew_user3\n",
- })
-
- filesTouched := []string{"main.go"}
-
- // PromptAttribution captured that user added 5 lines between checkpoints
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 2,
- UserLinesAdded: 5,
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{"main.go": 5}, // KEY: per-file tracking
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result, "expected non-nil result")
-
- // Expected calculation with per-file tracking:
- // - base → shadow: 15 lines added (10 agent + 5 user)
- // - accumulatedUserAdded: 5 (from PromptAttribution)
- // - totalAgentAdded: 15 - 5 = 10
- // - shadow → head: +3 lines added, -3 lines removed (user modification)
- // - totalUserAdded: 5 + 3 = 8
- // - totalUserRemoved: 3
- // - totalHumanModified: min(8, 3) = 3
- // - userSelfModified: min(3 removed from main.go, 5 user added to main.go) = 3
- // - humanModifiedAgent: 3 - 3 = 0 (no agent lines were modified!)
- // - agentLinesInCommit: 10 - 0 - 0 = 10 (CORRECT: agent lines unchanged)
- // - TotalCommitted = 10 + 5 = 15 (legacy net-additions metric)
- // - TotalLinesChanged = 10 agent + 5 added + 3 modified = 18
- // - Agent percentage: 10/18 = 55.6%
-
- t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, total=%d, percentage=%.1f%%",
- result.AgentLines, result.HumanAdded, result.HumanModified, result.TotalCommitted, result.AgentPercentage)
-
- if result.AgentLines != 10 {
- t.Errorf("AgentLines = %d, want 10 (agent lines should NOT be reduced by user self-modifications)", result.AgentLines)
- }
- if result.HumanAdded != 5 {
- t.Errorf("HumanAdded = %d, want 5 (8 total - 3 modifications)", result.HumanAdded)
- }
- if result.HumanModified != 3 {
- t.Errorf("HumanModified = %d, want 3 (total modifications for reporting)", result.HumanModified)
- }
- if result.TotalCommitted != 15 {
- t.Errorf("TotalCommitted = %d, want 15", result.TotalCommitted)
- }
- if result.TotalLinesChanged != 18 {
- t.Errorf("TotalLinesChanged = %d, want 18", result.TotalLinesChanged)
- }
- if result.AgentPercentage < 55.5 || result.AgentPercentage > 55.7 {
- t.Errorf("AgentPercentage = %.1f%%, want ~55.6%%", result.AgentPercentage)
- }
-}
-
-// TestCalculateAttributionWithAccumulated_MixedModifications tests the case where
-// user modifies both their own lines AND agent lines.
-func TestCalculateAttributionWithAccumulated_MixedModifications(t *testing.T) {
- // Base: empty file
- baseTree := buildTestTree(t, map[string]string{
- "main.go": "",
- })
-
- // Shadow: agent added 10 lines, user added 3 lines
- shadowTree := buildTestTree(t, map[string]string{
- "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nagent9\nagent10\nuser1\nuser2\nuser3\n",
- })
-
- // Head: user removed 5 lines (3 own + 2 agent) and added 5 new lines
- // Net effect: user modified 5 lines total
- headTree := buildTestTree(t, map[string]string{
- "main.go": "agent1\nagent2\nagent3\nagent4\nagent5\nagent6\nagent7\nagent8\nnew1\nnew2\nnew3\nnew4\nnew5\n",
- })
-
- filesTouched := []string{"main.go"}
-
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 2,
- UserLinesAdded: 3,
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{"main.go": 3},
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result, "expected non-nil result")
-
- // Expected calculation:
- // - base → shadow: 13 lines added (10 agent + 3 user)
- // - accumulatedUserAdded: 3
- // - totalAgentAdded: 13 - 3 = 10
- // - shadow → head: +5 added, -5 removed
- // - totalUserAdded: 3 + 5 = 8
- // - totalUserRemoved: 5
- // - totalHumanModified: min(8, 5) = 5
- // - userSelfModified: min(5 removed, 3 user added) = 3 (user exhausted their pool)
- // - humanModifiedAgent: 5 - 3 = 2 (2 modifications targeted agent lines)
- // - agentLinesInCommit: 10 - 0 - 2 = 8 (reduced by modifications to agent lines only)
- // - pureUserAdded: 8 - 5 = 3
- // - TotalCommitted = 10 + 3 = 13 (legacy net-additions metric)
- // - TotalLinesChanged = 8 agent + 3 added + 5 modified = 16
- // - Agent percentage: 8/16 = 50%
-
- t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, total=%d, percentage=%.1f%%",
- result.AgentLines, result.HumanAdded, result.HumanModified, result.TotalCommitted, result.AgentPercentage)
-
- if result.AgentLines != 8 {
- t.Errorf("AgentLines = %d, want 8 (10 - 2 modifications to agent lines)", result.AgentLines)
- }
- if result.HumanModified != 5 {
- t.Errorf("HumanModified = %d, want 5", result.HumanModified)
- }
- if result.TotalCommitted != 13 {
- t.Errorf("TotalCommitted = %d, want 13", result.TotalCommitted)
- }
- if result.TotalLinesChanged != 16 {
- t.Errorf("TotalLinesChanged = %d, want 16", result.TotalLinesChanged)
- }
- if result.AgentPercentage < 49.9 || result.AgentPercentage > 50.1 {
- t.Errorf("AgentPercentage = %.1f%%, want 50.0%%", result.AgentPercentage)
- }
-}
-
-// TestCalculateAttributionWithAccumulated_UncommittedWorktreeFiles tests the bug where
-// files in the worktree but NOT in the commit inflate the attribution calculation.
-//
-// Bug scenario:
-// 1. Agent creates docs/example.md (17 lines)
-// 2. .claude/settings.json (84 lines) exists in worktree from agent setup
-// 3. calculatePromptAttributionAtStart captures .claude/settings.json as user change
-// 4. User commits only docs/example.md (git add docs/ && git commit)
-// 5. BUG: accumulatedUserAdded=84 inflates totalUserAdded and totalCommitted
-// 6. Result: agentPercentage = 17/101 = 16.8% instead of 100%
-func TestCalculateAttributionWithAccumulated_UncommittedWorktreeFiles(t *testing.T) {
- t.Parallel()
-
- // Base: empty tree (initial --allow-empty commit)
- baseTree := buildTestTree(t, nil)
-
- // Shadow (agent checkpoint): agent created example.md
- agentContent := "# Software Testing\n\nSoftware testing is a critical part of the development process.\n\n## Types of Testing\n\n- Unit testing\n- Integration testing\n- End-to-end testing\n\n## Best Practices\n\nWrite tests early.\nAutomate where possible.\nTest edge cases.\nReview test coverage.\n"
- shadowTree := buildTestTree(t, map[string]string{
- "example.md": agentContent,
- })
-
- // Head (committed): same file, only example.md was committed
- // .claude/settings.json is NOT in the head tree (not committed)
- headTree := buildTestTree(t, map[string]string{
- "example.md": agentContent,
- })
-
- filesTouched := []string{"example.md"}
-
- // PromptAttribution captured .claude/settings.json (84 lines) as user change
- // at prompt start, because it was in the worktree but not in the base tree.
- // This is the root cause of the bug: these 84 lines are never committed.
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 1,
- UserLinesAdded: 84,
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{".claude/settings.json": 84},
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result, "expected non-nil result")
-
- agentLines := countLinesStr(agentContent)
- t.Logf("Agent content has %d lines", agentLines)
- t.Logf("Attribution: agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
- result.AgentLines, result.HumanAdded, result.TotalCommitted, result.AgentPercentage)
-
- // Expected: agent created 100% of committed content
- // .claude/settings.json should NOT affect attribution since it was never committed
- if result.AgentLines != agentLines {
- t.Errorf("AgentLines = %d, want %d", result.AgentLines, agentLines)
- }
- if result.HumanAdded != 0 {
- t.Errorf("HumanAdded = %d, want 0 (.claude/settings.json was never committed)", result.HumanAdded)
- }
- if result.TotalCommitted != agentLines {
- t.Errorf("TotalCommitted = %d, want %d (only agent-created file was committed)", result.TotalCommitted, agentLines)
- }
- if result.AgentPercentage != 100.0 {
- t.Errorf("AgentPercentage = %.1f%%, want 100.0%% (agent created all committed content)", result.AgentPercentage)
- }
-}
-
-// TestCalculatePromptAttribution_PopulatesPerFile verifies that CalculatePromptAttribution
-// correctly populates the UserAddedPerFile map.
-func TestCalculatePromptAttribution_PopulatesPerFile(t *testing.T) {
- // Base: two files
- baseTree := buildTestTree(t, map[string]string{
- "a.go": "line1\n",
- "b.go": "line1\n",
- })
-
- // Last checkpoint: agent added lines to both files
- lastCheckpointTree := buildTestTree(t, map[string]string{
- "a.go": "line1\nagent1\n",
- "b.go": "line1\nagent1\nagent2\n",
- })
-
- // Current worktree: user added lines to both files
- worktreeFiles := map[string]string{
- "a.go": "line1\nagent1\nuser1\nuser2\nuser3\n", // +3 user lines
- "b.go": "line1\nagent1\nagent2\nuser1\n", // +1 user line
- }
-
- result := CalculatePromptAttribution(baseTree, lastCheckpointTree, worktreeFiles, 2)
-
- if result.UserLinesAdded != 4 {
- t.Errorf("UserLinesAdded = %d, want 4 (3 + 1)", result.UserLinesAdded)
- }
-
- if result.UserAddedPerFile == nil {
- t.Fatal("UserAddedPerFile should not be nil")
- }
-
- if result.UserAddedPerFile["a.go"] != 3 {
- t.Errorf("UserAddedPerFile[a.go] = %d, want 3", result.UserAddedPerFile["a.go"])
- }
- if result.UserAddedPerFile["b.go"] != 1 {
- t.Errorf("UserAddedPerFile[b.go] = %d, want 1", result.UserAddedPerFile["b.go"])
- }
-}
-
-// TestCalculateAttributionWithAccumulated_PreSessionDirtOnAgentFiles verifies that
-// pre-session worktree dirt (captured in PA1 / checkpoint 1) on files the agent later
-// touches does NOT get counted as human contributions.
-//
-// Scenario: hooks.go has 3 pre-session dirty lines when session starts.
-// Agent also modifies hooks.go (adds 5 more lines). Shadow captures all 8 new lines.
-// At commit time, the 3 pre-session lines should be excluded from human count.
-func TestCalculateAttributionWithAccumulated_PreSessionDirtOnAgentFiles(t *testing.T) {
- t.Parallel()
-
- // Base: hooks.go has 3 lines
- baseTree := buildTestTree(t, map[string]string{
- "hooks.go": "package strategy\n\nfunc warn() {}\n",
- })
-
- // Shadow captures base (3 lines) + pre-session dirt (3 new lines) + agent work (5 new lines)
- // = 11 total lines, 8 added relative to base
- shadowContent := "package strategy\n\n// pre1\n// pre2\n// pre3\nfunc agentA() {}\nfunc agentB() {}\nfunc agentC() {}\nfunc agentD() {}\nfunc agentE() {}\nfunc warn() {}\n"
- shadowTree := buildTestTree(t, map[string]string{
- "hooks.go": shadowContent,
- })
-
- // Head = shadow (user didn't edit after agent)
- headTree := shadowTree
-
- filesTouched := []string{"hooks.go"}
-
- // PA1 captured the 3 pre-session dirty lines at session start
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 1,
- UserLinesAdded: 3,
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{"hooks.go": 3},
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result)
-
- // base→shadow adds 8 lines. PA1 says 3 are pre-session.
- // totalAgentAdded = 8 - 3 = 5 (correct agent subtraction).
- // Pre-session 3 lines should NOT appear in HumanAdded.
- require.Equal(t, 5, result.AgentLines, "agent should get credit for 5 lines")
- require.Equal(t, 0, result.HumanAdded, "pre-session dirt should not count as human")
- require.Equal(t, 5, result.TotalCommitted, "total should be agent-only")
- require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
-}
-
-// TestCalculateAttributionWithAccumulated_PreSessionConfigFiles verifies that
-// non-agent files dirty at session start (e.g., CLI config files from `trace enable`)
-// do NOT get counted as human contributions.
-//
-// Uses flat file names because buildTestTree doesn't support nested paths.
-// The attribution code only checks filesTouched membership and UserAddedPerFile keys,
-// so flat names are equivalent for testing.
-func TestCalculateAttributionWithAccumulated_PreSessionConfigFiles(t *testing.T) {
- t.Parallel()
-
- // Base: empty repo
- baseTree := buildTestTree(t, map[string]string{
- "empty": "",
- })
-
- // Shadow: agent created hello.py (5 lines). Config file also present (10 lines).
- shadowTree := buildTestTree(t, map[string]string{
- "empty": "",
- "hello.py": "line1\nline2\nline3\nline4\nline5\n",
- "config.json": "k1\nk2\nk3\nk4\nk5\nk6\nk7\nk8\nk9\nk10\n",
- })
-
- // Head = shadow (user didn't edit)
- headTree := shadowTree
-
- filesTouched := []string{"hello.py"}
-
- // PA1 captured the config file at session start (pre-session dirty)
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 1,
- UserLinesAdded: 10,
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{"config.json": 10},
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result)
-
- // Agent created hello.py (5 lines). Config file is pre-session baseline — excluded.
- require.Equal(t, 5, result.AgentLines, "agent should get 5 lines for hello.py")
- require.Equal(t, 0, result.HumanAdded, "pre-session config should not count as human")
- require.Equal(t, 5, result.TotalCommitted, "total should be agent-only")
- require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
-}
-
-// TestCalculateAttributionWithAccumulated_DuringSessionHumanEdits verifies that
-// human edits made DURING the session (captured by PA2+) are still correctly
-// counted as human contributions after the baseline fix.
-//
-// This is a correctness guard — the fix must not break this.
-func TestCalculateAttributionWithAccumulated_DuringSessionHumanEdits(t *testing.T) {
- t.Parallel()
-
- baseTree := buildTestTree(t, map[string]string{
- "main.go": "",
- })
-
- // Shadow: 12 lines total — 10 agent + 2 user (added between turns)
- shadowTree := buildTestTree(t, map[string]string{
- "main.go": "a1\na2\na3\na4\na5\na6\na7\na8\nu1\nu2\na9\na10\n",
- })
-
- headTree := shadowTree
-
- filesTouched := []string{"main.go"}
-
- promptAttributions := []PromptAttribution{
- {
- CheckpointNumber: 1,
- UserLinesAdded: 0, // Clean worktree at session start
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{},
- },
- {
- CheckpointNumber: 2,
- UserLinesAdded: 2, // User added 2 lines between turn 1 and 2
- UserLinesRemoved: 0,
- UserAddedPerFile: map[string]int{"main.go": 2},
- },
- }
-
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, result)
-
- // 12 total lines in shadow. PA2 says user added 2. Agent = 12 - 2 = 10.
- require.Equal(t, 10, result.AgentLines, "agent should get 10 lines")
- require.Equal(t, 2, result.HumanAdded, "user's 2 lines from PA2 should count")
- require.Equal(t, 12, result.TotalCommitted)
- require.InDelta(t, 83.3, result.AgentPercentage, 0.1)
-}
-
-// TestCalculateAttributionWithAccumulated_EmptyPA verifies that sessions with
-// no prompt attributions (old CLI versions, edge cases) still work correctly.
-func TestCalculateAttributionWithAccumulated_EmptyPA(t *testing.T) {
- t.Parallel()
-
- baseTree := buildTestTree(t, map[string]string{
- "main.go": "",
- })
-
- shadowTree := buildTestTree(t, map[string]string{
- "main.go": "line1\nline2\nline3\n",
- })
-
- headTree := shadowTree
- filesTouched := []string{"main.go"}
-
- // No prompt attributions at all (old session or edge case)
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched,
- })
-
- require.NotNil(t, result)
- require.Equal(t, 3, result.AgentLines)
- require.Equal(t, 0, result.HumanAdded)
- require.InDelta(t, 100.0, result.AgentPercentage, 0.1)
-}
-
-// TestCalculateAttributionWithAccumulated_ParentTreeForNonAgentLines verifies that
-// non-agent file line counting uses parentTree (not baseTree) when provided.
-// This prevents inflation in multi-commit sessions where a non-agent file was
-// modified in an intermediate commit AND the current commit.
-//
-// Scenario (multi-commit session):
-// - Session starts at commit A: readme.md has 2 lines
-// - Commit B: user adds 5 lines to readme.md (intermediate commit)
-// - Commit C (current): agent modifies main.go, user adds 3 more lines to readme.md
-//
-// Without parentTree: diffLines(baseTree=A, headTree=C) counts ALL 8 lines → inflated
-// With parentTree: diffLines(parentTree=B, headTree=C) counts only 3 lines → correct
-func TestCalculateAttributionWithAccumulated_ParentTreeForNonAgentLines(t *testing.T) {
- t.Parallel()
-
- // baseTree = commit A: readme.md has 2 lines, main.go is empty
- baseTree := buildTestTree(t, map[string]string{
- "main.go": "",
- "readme.md": "line1\nline2\n",
- })
-
- // parentTree = commit B: readme.md grew to 7 lines (user added 5 in intermediate commit)
- parentTree := buildTestTree(t, map[string]string{
- "main.go": "",
- "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\n",
- })
-
- // shadowTree: agent added 4 lines to main.go (checkpoint state)
- shadowTree := buildTestTree(t, map[string]string{
- "main.go": "func a() {}\nfunc b() {}\nfunc c() {}\nfunc d() {}\n",
- "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\n",
- })
-
- // headTree = commit C: agent's main.go + user added 3 more lines to readme.md
- headTree := buildTestTree(t, map[string]string{
- "main.go": "func a() {}\nfunc b() {}\nfunc c() {}\nfunc d() {}\n",
- "readme.md": "line1\nline2\ninter1\ninter2\ninter3\ninter4\ninter5\nnew1\nnew2\nnew3\n",
- })
-
- filesTouched := []string{"main.go"}
-
- // No prompt attributions (clean worktree at session start)
- promptAttributions := []PromptAttribution{}
-
- // WITH parentTree: should only count 3 new readme.md lines (parent→head)
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- ParentTree: parentTree,
- })
-
- require.NotNil(t, result)
- require.Equal(t, 4, result.AgentLines, "agent added 4 lines to main.go")
- require.Equal(t, 3, result.HumanAdded, "only 3 lines from THIS commit, not all 8 since session start")
- require.Equal(t, 7, result.TotalCommitted, "4 agent + 3 human")
- require.InDelta(t, 57.1, result.AgentPercentage, 0.2, "4/7 = 57.1%")
-
- // WITHOUT parentTree (nil): would count all 8 lines since session start — verify the bug
- resultNoPT := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, resultNoPT)
- // Without parentTree, falls back to baseTree: counts 8 lines (all since session start)
- require.Equal(t, 8, resultNoPT.HumanAdded, "without parentTree, all 8 lines counted (inflated)")
-}
-
-// TestCalculateAttributionWithAccumulated_MultiSessionCrossExclusion verifies that
-// files touched by OTHER agent sessions in the same commit are not counted as human work.
-//
-// Scenario: two sessions create files, then both are committed together.
-// - Session 0 created blue.md (3 lines)
-// - Session 1 created red.md (3 lines)
-//
-// When calculating Session 0's attribution, red.md should be excluded via AllAgentFiles
-// (the union of all sessions' FilesTouched), not counted as human_added.
-func TestCalculateAttributionWithAccumulated_MultiSessionCrossExclusion(t *testing.T) {
- t.Parallel()
-
- baseTree := buildTestTree(t, nil)
-
- // Shadow: Session 0 created blue.md
- shadowTree := buildTestTree(t, map[string]string{
- "blue.md": "line1\nline2\nline3\n",
- })
-
- // Head: commit contains both blue.md and red.md (from two sessions)
- headTree := buildTestTree(t, map[string]string{
- "blue.md": "line1\nline2\nline3\n",
- "red.md": "line1\nline2\nline3\n",
- })
-
- // Session 0 only touched blue.md
- filesTouched := []string{"blue.md"}
-
- promptAttributions := []PromptAttribution{
- {CheckpointNumber: 1, UserAddedPerFile: map[string]int{}},
- }
-
- // AllAgentFiles = union of ALL sessions' FilesTouched
- allAgentFiles := map[string]struct{}{
- "blue.md": {},
- "red.md": {}, // From Session 1
- }
-
- // WITH AllAgentFiles: red.md excluded from human count
- result := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- AllAgentFiles: allAgentFiles,
- })
-
- require.NotNil(t, result)
- require.Equal(t, 3, result.AgentLines, "agent should get 3 lines for blue.md")
- require.Equal(t, 0, result.HumanAdded, "red.md should NOT count as human (other agent session)")
- require.Equal(t, 3, result.TotalCommitted, "total should be agent-only for this session's scope")
- require.InDelta(t, 100.0, result.AgentPercentage, 0.1, "should be 100%% agent")
-
- // WITHOUT AllAgentFiles: red.md incorrectly counted as human (the bug)
- resultNoExcl := CalculateAttributionWithAccumulated(context.Background(), AttributionParams{
- BaseTree: baseTree, ShadowTree: shadowTree, HeadTree: headTree,
- FilesTouched: filesTouched, PromptAttributions: promptAttributions,
- })
-
- require.NotNil(t, resultNoExcl)
- require.Equal(t, 3, resultNoExcl.HumanAdded, "without AllAgentFiles, red.md counted as human (inflated)")
- require.Equal(t, 6, resultNoExcl.TotalCommitted, "inflated total includes red.md as human")
-}
-
-// TestWarnIfAttributionDiverged_MultipleDivergentSessions_FlagsAllOnce verifies that
-// when multiple sessions have attribution divergence, the stderr warning is printed
-// exactly once per call and the DivergenceNoticeShown flag is persisted on every
-// divergent session — not just the first. The previous implementation broke out of the
-// loop after flagging the first session, which caused the "show-once" warning to
-// re-trigger on later prepare-commit-msg invocations for each additional divergent
-// session.
-func TestWarnIfAttributionDiverged_MultipleDivergentSessions_FlagsAllOnce(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- now := time.Now()
- sessions := []*SessionState{
- {
- SessionID: "diverged-a",
- BaseCommit: strings.Repeat("a", 40),
- AttributionBaseCommit: strings.Repeat("b", 40),
- StartedAt: now,
- },
- {
- SessionID: "diverged-b",
- BaseCommit: strings.Repeat("c", 40),
- AttributionBaseCommit: strings.Repeat("d", 40),
- StartedAt: now,
- },
- }
- for _, sess := range sessions {
- require.NoError(t, s.saveSessionState(context.Background(), sess))
- }
-
- var buf bytes.Buffer
- oldWriter := stderrWriter
- stderrWriter = &buf
- defer func() { stderrWriter = oldWriter }()
-
- s.warnIfAttributionDiverged(context.Background(), sessions)
-
- require.Equal(t, 1, strings.Count(buf.String(), "trace: session attribution diverged"),
- "warning must print exactly once even with multiple divergent sessions, got:\n%s", buf.String())
-
- for _, sess := range sessions {
- require.True(t, sess.DivergenceNoticeShown,
- "DivergenceNoticeShown must be set on every divergent session (session %s)",
- sess.SessionID)
-
- // The flag must also be persisted to disk — the whole point of "show-once"
- // is cross-invocation suppression. An in-memory-only mutation would let the
- // warning re-fire on the next prepare-commit-msg.
- reloaded, err := s.loadSessionState(context.Background(), sess.SessionID)
- require.NoError(t, err)
- require.NotNil(t, reloaded, "session %s should be persisted", sess.SessionID)
- require.True(t, reloaded.DivergenceNoticeShown,
- "DivergenceNoticeShown must be persisted to disk for session %s", sess.SessionID)
- }
-
- // Second call on the same slice must print nothing — flags are already set.
- buf.Reset()
- s.warnIfAttributionDiverged(context.Background(), sessions)
- require.Empty(t, buf.String(),
- "warning must stay silent on subsequent calls once every divergent session has been flagged")
-}
diff --git a/cli/strategy/manual_commit_condensation.go b/cli/strategy/manual_commit_condensation.go
index 6c85348..14e9b10 100644
--- a/cli/strategy/manual_commit_condensation.go
+++ b/cli/strategy/manual_commit_condensation.go
@@ -4,34 +4,21 @@ import (
"bytes"
"context"
"encoding/json"
- "errors"
"fmt"
"log/slog"
- "os"
- "path/filepath"
- "slices"
- "strconv"
- "strings"
"time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/external"
- "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid"
"github.com/GrayCodeAI/trace/cli/agent/geminicli"
"github.com/GrayCodeAI/trace/cli/agent/opencode"
"github.com/GrayCodeAI/trace/cli/agent/types"
cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
"github.com/GrayCodeAI/trace/cli/logging"
- "github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/summarize"
- "github.com/GrayCodeAI/trace/cli/textutil"
"github.com/GrayCodeAI/trace/cli/transcript"
- "github.com/GrayCodeAI/trace/cli/transcript/compact"
- "github.com/GrayCodeAI/trace/cli/versioninfo"
"github.com/GrayCodeAI/trace/perf"
"github.com/GrayCodeAI/trace/redact"
@@ -823,1042 +810,3 @@ type attributionOpts struct {
headCommitHash string // HEAD commit hash for non-agent file detection (empty = fall back to go-git tree walk)
allAgentFiles map[string]struct{} // Union of all sessions' FilesTouched (nil = single-session)
}
-
-func calculateSessionAttributions(ctx context.Context, repo *git.Repository, shadowRef *plumbing.Reference, sessionData *ExtractedSessionData, state *SessionState, opts ...attributionOpts) *cpkg.InitialAttribution {
- // Calculate initial attribution using accumulated prompt attribution data.
- // This uses user edits captured at each prompt start (before agent works),
- // plus any user edits after the final checkpoint (shadow → head).
- //
- // When shadowRef is nil (agent committed mid-turn before SaveStep),
- // HEAD is used as the shadow tree. This is correct because the agent's
- // commit IS HEAD — there are no user edits between agent work and commit.
- logCtx := logging.WithComponent(ctx, "attribution")
-
- var o attributionOpts
- if len(opts) > 0 {
- o = opts[0]
- }
-
- headTree := o.headTree
- if headTree == nil {
- headRef, headErr := repo.Head()
- if headErr != nil {
- logging.Debug(logCtx, "attribution skipped: failed to get HEAD",
- slog.String("error", headErr.Error()))
- return nil
- }
-
- headCommit, commitErr := repo.CommitObject(headRef.Hash())
- if commitErr != nil {
- logging.Debug(logCtx, "attribution skipped: failed to get HEAD commit",
- slog.String("error", commitErr.Error()))
- return nil
- }
-
- var treeErr error
- headTree, treeErr = headCommit.Tree()
- if treeErr != nil {
- logging.Debug(logCtx, "attribution skipped: failed to get HEAD tree",
- slog.String("error", treeErr.Error()))
- return nil
- }
- }
-
- // Get shadow tree: from pre-resolved cache, shadow branch, or HEAD (agent committed directly).
- shadowTree := o.shadowTree
- if shadowTree == nil {
- if shadowRef != nil {
- shadowCommit, shadowErr := repo.CommitObject(shadowRef.Hash())
- if shadowErr != nil {
- logging.Debug(logCtx, "attribution skipped: failed to get shadow commit",
- slog.String("error", shadowErr.Error()),
- slog.String("shadow_ref", shadowRef.Hash().String()))
- return nil
- }
- var shadowTreeErr error
- shadowTree, shadowTreeErr = shadowCommit.Tree()
- if shadowTreeErr != nil {
- logging.Debug(logCtx, "attribution skipped: failed to get shadow tree",
- slog.String("error", shadowTreeErr.Error()))
- return nil
- }
- } else {
- // No shadow branch: agent committed mid-turn. Use HEAD as shadow
- // because the agent's work is the commit itself.
- logging.Debug(logCtx, "attribution: using HEAD as shadow (no shadow branch)")
- shadowTree = headTree
- }
- }
-
- // Get base tree (state before session started)
- var baseTree *object.Tree
- attrBase := state.AttributionBaseCommit
- if attrBase == "" {
- attrBase = state.BaseCommit // backward compat
- }
- if baseCommit, baseErr := repo.CommitObject(plumbing.NewHash(attrBase)); baseErr == nil {
- if tree, baseTErr := baseCommit.Tree(); baseTErr == nil {
- baseTree = tree
- } else {
- logging.Debug(logCtx, "attribution: base tree unavailable",
- slog.String("error", baseTErr.Error()))
- }
- } else {
- logging.Debug(logCtx, "attribution: base commit unavailable",
- slog.String("error", baseErr.Error()),
- slog.String("attribution_base", attrBase))
- }
-
- // Include PendingPromptAttribution if it was never moved to PromptAttributions.
- // This happens when an agent commits mid-turn without calling SaveStep (e.g., Codex).
- // PendingPromptAttribution is set during UserPromptSubmit but only moved to
- // PromptAttributions during SaveStep. Without this, mid-turn commits have no PA
- // data and pre-session worktree dirt cannot be identified for baseline exclusion.
- promptAttrs := state.PromptAttributions
- if state.PendingPromptAttribution != nil {
- promptAttrs = append(promptAttrs, *state.PendingPromptAttribution)
- }
-
- // Log accumulated prompt attributions for debugging
- var totalUserAdded, totalUserRemoved int
- for i, pa := range promptAttrs {
- totalUserAdded += pa.UserLinesAdded
- totalUserRemoved += pa.UserLinesRemoved
- logging.Debug(logCtx, "prompt attribution data",
- slog.Int("checkpoint", pa.CheckpointNumber),
- slog.Int("user_added", pa.UserLinesAdded),
- slog.Int("user_removed", pa.UserLinesRemoved),
- slog.Int("agent_added", pa.AgentLinesAdded),
- slog.Int("agent_removed", pa.AgentLinesRemoved),
- slog.Int("index", i))
- }
-
- attribution := CalculateAttributionWithAccumulated(ctx, AttributionParams{
- BaseTree: baseTree,
- ShadowTree: shadowTree,
- HeadTree: headTree,
- ParentTree: o.parentTree,
- FilesTouched: sessionData.FilesTouched,
- PromptAttributions: promptAttrs,
- RepoDir: o.repoDir,
- ParentCommitHash: o.parentCommitHash,
- AttributionBaseCommit: attrBase,
- HeadCommitHash: o.headCommitHash,
- AllAgentFiles: o.allAgentFiles,
- })
-
- if attribution != nil {
- logging.Info(logCtx, "attribution calculated",
- slog.Int("agent_lines", attribution.AgentLines),
- slog.Int("human_added", attribution.HumanAdded),
- slog.Int("human_modified", attribution.HumanModified),
- slog.Int("human_removed", attribution.HumanRemoved),
- slog.Int("total_committed", attribution.TotalCommitted),
- slog.Float64("agent_percentage", attribution.AgentPercentage),
- slog.Int("accumulated_user_added", totalUserAdded),
- slog.Int("accumulated_user_removed", totalUserRemoved),
- slog.Int("files_touched", len(sessionData.FilesTouched)))
- }
-
- return attribution
-}
-
-// committedFilesExcludingMetadata returns committed files with CLI metadata paths filtered out.
-// `.trace/` files are created by `trace enable`, not by the agent, and should not be
-// attributed as agent work when used as a fallback for sessions with no FilesTouched.
-func committedFilesExcludingMetadata(committedFiles map[string]struct{}) []string {
- result := make([]string, 0, len(committedFiles))
- for f := range committedFiles {
- if strings.HasPrefix(f, ".trace/") || strings.HasPrefix(f, paths.TraceMetadataDir+"/") {
- continue
- }
- result = append(result, f)
- }
- slices.Sort(result)
- return result
-}
-
-// extractSessionData extracts session data from the shadow branch.
-// filesTouched is the list of files tracked during the session (from SessionState.FilesTouched).
-// agentType identifies the agent (e.g., "Gemini CLI", "Claude Code") to determine transcript format.
-// liveTranscriptPath, when non-empty and readable, is preferred over the shadow branch copy.
-// This handles the case where SaveStep was skipped (no code changes) but the transcript
-// continued growing — the shadow branch copy would be stale.
-// checkpointTranscriptStart is the line offset (Claude) or message index (Gemini) where the current checkpoint began.
-func (s *ManualCommitStrategy) extractSessionData(ctx context.Context, repo *git.Repository, shadowRef plumbing.Hash, sessionID string, filesTouched []string, agentType types.AgentType, liveTranscriptPath string, checkpointTranscriptStart int, isActive bool) (*ExtractedSessionData, error) {
- ag, _ := agent.GetByAgentType(agentType) //nolint:errcheck // ag may be nil for unknown agent types; callers use type assertions so nil is safe
- commit, err := repo.CommitObject(shadowRef)
- if err != nil {
- return nil, fmt.Errorf("failed to get commit object: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("failed to get commit tree: %w", err)
- }
-
- data := &ExtractedSessionData{}
- // sessionID is already an "trace session ID" (with date prefix)
- metadataDir := paths.SessionMetadataDirFromSessionID(sessionID)
-
- // Extract transcript — prefer the live file when available, fall back to shadow branch.
- // The shadow branch copy may be stale if the last turn ended without code changes
- // (SaveStep is only called when there are file modifications).
- var fullTranscript string
- if liveTranscriptPath != "" {
- // Ensure transcript file exists (OpenCode creates it lazily via `opencode export`).
- // Only wait for flush when the session is active — for idle/ended sessions the
- // transcript is already fully flushed (the Stop hook completed the flush).
- if isActive {
- prepareTranscriptIfNeeded(ctx, ag, liveTranscriptPath)
- }
- if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 { //nolint:gosec // path from session state
- fullTranscript = string(liveData)
- }
- }
- if fullTranscript == "" {
- // Fall back to shadow branch copy
- if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileName); fileErr == nil {
- if content, contentErr := file.Contents(); contentErr == nil {
- fullTranscript = content
- }
- } else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil {
- if content, contentErr := file.Contents(); contentErr == nil {
- fullTranscript = content
- }
- }
- }
-
- // Process transcript based on agent type
- if fullTranscript != "" {
- data.Transcript = []byte(fullTranscript)
- data.FullTranscriptLines = countTranscriptItems(agentType, fullTranscript)
- // Read prompts from shadow branch tree (source of truth after SaveStep)
- if file, fileErr := tree.File(metadataDir + "/" + paths.PromptFileName); fileErr == nil {
- if content, contentErr := file.Contents(); contentErr == nil && content != "" {
- data.Prompts = splitPromptContent(content)
- }
- }
- // Filesystem fallback (written at turn start, covers mid-turn commits)
- if len(data.Prompts) == 0 {
- data.Prompts = readPromptsFromFilesystem(ctx, sessionID)
- }
- }
-
- // Use tracked files from session state (not all files in tree)
- data.FilesTouched = filesTouched
-
- // Calculate token usage from the extracted transcript portion
- if len(data.Transcript) > 0 {
- // Derive subagents directory from the live transcript path when available.
- // Pattern: //subagents (same as manual_commit_hooks.go)
- var subagentsDir string
- if liveTranscriptPath != "" {
- subagentsDir = filepath.Join(filepath.Dir(liveTranscriptPath), sessionID, "subagents")
- }
- data.TokenUsage = agent.CalculateTokenUsage(ctx, ag, data.Transcript, checkpointTranscriptStart, subagentsDir)
- }
-
- return data, nil
-}
-
-// extractSessionDataFromLiveTranscript extracts session data directly from the live transcript file.
-// This is used for mid-session commits where no shadow branch exists yet.
-func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context.Context, state *SessionState) (*ExtractedSessionData, error) {
- data := &ExtractedSessionData{}
-
- ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; callers use type assertions so nil is safe
-
- // Resolve the transcript path (handles agents that relocate mid-session).
- transcriptPath, resolveErr := resolveTranscriptPath(state)
- if resolveErr != nil {
- return nil, resolveErr
- }
-
- liveData, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath
- if err != nil {
- return nil, fmt.Errorf("failed to read live transcript: %w", err)
- }
-
- if len(liveData) == 0 {
- return nil, errors.New("live transcript is empty")
- }
-
- fullTranscript := string(liveData)
- data.Transcript = liveData
- data.FullTranscriptLines = countTranscriptItems(state.AgentType, fullTranscript)
- data.Prompts = readPromptsFromFilesystem(ctx, state.SessionID)
-
- // Resolve files touched: prefers hook-populated state, falls back to transcript extraction
- data.FilesTouched = s.resolveFilesTouched(ctx, state)
-
- // Calculate token usage from the extracted transcript portion
- if len(data.Transcript) > 0 {
- // Derive subagents directory from the transcript path when available.
- // Pattern: //subagents (same as manual_commit_hooks.go)
- var subagentsDir string
- if state.TranscriptPath != "" {
- subagentsDir = filepath.Join(filepath.Dir(state.TranscriptPath), state.SessionID, "subagents")
- }
- data.TokenUsage = agent.CalculateTokenUsage(ctx, ag, data.Transcript, state.CheckpointTranscriptStart, subagentsDir)
- }
-
- return data, nil
-}
-
-// countTranscriptItems counts lines (JSONL) or messages (JSON) in a transcript.
-// For Claude Code and JSONL-based agents, this counts lines.
-// For Gemini CLI, OpenCode, and JSON-based agents, this counts messages.
-// Returns 0 if the content is empty or malformed.
-func countTranscriptItems(agentType types.AgentType, content string) int {
- if content == "" {
- return 0
- }
-
- // OpenCode uses export JSON format with {"info": {...}, "messages": [...]}
- if agentType == agent.AgentTypeOpenCode {
- session, err := opencode.ParseExportSession([]byte(content))
- if err == nil && session != nil {
- return len(session.Messages)
- }
- return 0
- }
-
- // Try Gemini format first if agentType is Gemini, or as fallback if Unknown
- if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown {
- transcript, err := geminicli.ParseTranscript([]byte(content))
- if err == nil && transcript != nil && len(transcript.Messages) > 0 {
- return len(transcript.Messages)
- }
- // If agentType is explicitly Gemini but parsing failed, return 0
- if agentType == agent.AgentTypeGemini {
- return 0
- }
- // Otherwise fall through to JSONL parsing for Unknown type
- }
-
- // Claude Code and other JSONL-based agents
- allLines := strings.Split(content, "\n")
- // Trim trailing empty lines (from final \n in JSONL)
- for len(allLines) > 0 && strings.TrimSpace(allLines[len(allLines)-1]) == "" {
- allLines = allLines[:len(allLines)-1]
- }
- return len(allLines)
-}
-
-// extractUserPrompts extracts all user prompts from transcript content.
-// Returns prompts with IDE context tags stripped (e.g., ).
-func extractUserPrompts(agentType types.AgentType, content string) []string {
- if content == "" {
- return nil
- }
-
- // Droid has its own envelope format — use its parser to normalize first
- if agentType == agent.AgentTypeFactoryAIDroid {
- lines, _, err := factoryaidroid.ParseDroidTranscriptFromBytes([]byte(content), 0)
- if err != nil {
- return nil
- }
- var prompts []string
- for _, line := range lines {
- if line.Type != transcript.TypeUser {
- continue
- }
- if text := transcript.ExtractUserContent(line.Message); text != "" {
- if stripped := textutil.StripIDEContextTags(text); stripped != "" {
- prompts = append(prompts, stripped)
- }
- }
- }
- return prompts
- }
-
- // OpenCode uses JSONL with a different per-line schema than Claude Code
- if agentType == agent.AgentTypeOpenCode {
- prompts, err := opencode.ExtractAllUserPrompts([]byte(content))
- if err == nil && len(prompts) > 0 {
- cleaned := make([]string, 0, len(prompts))
- for _, prompt := range prompts {
- if stripped := textutil.StripIDEContextTags(prompt); stripped != "" {
- cleaned = append(cleaned, stripped)
- }
- }
- return cleaned
- }
- return nil
- }
-
- // Try Gemini format first if agentType is Gemini, or as fallback if Unknown
- if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown {
- prompts, err := geminicli.ExtractAllUserPrompts([]byte(content))
- if err == nil && len(prompts) > 0 {
- // Strip IDE context tags for consistency with Claude Code handling
- cleaned := make([]string, 0, len(prompts))
- for _, prompt := range prompts {
- if stripped := textutil.StripIDEContextTags(prompt); stripped != "" {
- cleaned = append(cleaned, stripped)
- }
- }
- return cleaned
- }
- // If agentType is explicitly Gemini but parsing failed, return nil
- if agentType == agent.AgentTypeGemini {
- return nil
- }
- // Otherwise fall through to JSONL parsing for Unknown type
- }
-
- // Claude Code and other JSONL-based agents
- return extractUserPromptsFromLines(strings.Split(content, "\n"))
-}
-
-// extractUserPromptsFromLines extracts user prompts from JSONL transcript lines.
-// IDE-injected context tags (like ) are stripped from the results.
-func extractUserPromptsFromLines(lines []string) []string {
- var prompts []string
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
-
- var entry map[string]interface{}
- if err := json.Unmarshal([]byte(line), &entry); err != nil {
- continue
- }
-
- // Check for user message:
- // - Claude Code uses "type": "human" or "type": "user"
- // - Cursor uses "role": "user"
- msgType, _ := entry["type"].(string) //nolint:errcheck // type assertion on interface{} from JSON
- msgRole, _ := entry["role"].(string) //nolint:errcheck // type assertion on interface{} from JSON
- isUser := msgType == "human" || msgType == "user" || msgRole == "user"
- if !isUser {
- continue
- }
-
- // Extract message content
- message, ok := entry["message"].(map[string]interface{})
- if !ok {
- continue
- }
-
- // Handle string content
- if content, ok := message["content"].(string); ok && content != "" {
- cleaned := textutil.StripIDEContextTags(content)
- if cleaned != "" {
- prompts = append(prompts, cleaned)
- }
- continue
- }
-
- // Handle array content (e.g., multiple text blocks from VSCode)
- if arr, ok := message["content"].([]interface{}); ok {
- var texts []string
- for _, item := range arr {
- if m, ok := item.(map[string]interface{}); ok {
- if m["type"] == "text" {
- if text, ok := m["text"].(string); ok {
- texts = append(texts, text)
- }
- }
- }
- }
- if len(texts) > 0 {
- cleaned := textutil.StripIDEContextTags(strings.Join(texts, "\n\n"))
- if cleaned != "" {
- prompts = append(prompts, cleaned)
- }
- }
- }
- }
- return prompts
-}
-
-// splitPromptContent splits prompt.txt content on the "\n\n---\n\n" separator.
-// Returns nil if content is empty.
-func splitPromptContent(content string) []string {
- if content == "" {
- return nil
- }
- parts := strings.Split(content, "\n\n---\n\n")
- var result []string
- for _, p := range parts {
- trimmed := strings.TrimSpace(p)
- if trimmed != "" {
- result = append(result, trimmed)
- }
- }
- return result
-}
-
-// readPromptsFromFilesystem reads prompt.txt from the filesystem session metadata directory.
-// This file is written at turn start and updated at each SaveStep, providing prompt data
-// even for mid-turn commits where the shadow branch may not have been updated.
-func readPromptsFromFilesystem(ctx context.Context, sessionID string) []string {
- sessionDir := paths.SessionMetadataDirFromSessionID(sessionID)
- sessionDirAbs, err := paths.AbsPath(ctx, sessionDir)
- if err != nil {
- return nil
- }
- data, err := os.ReadFile(filepath.Join(sessionDirAbs, paths.PromptFileName)) //nolint:gosec // path from session ID
- if err != nil || len(data) == 0 {
- return nil
- }
- return splitPromptContent(string(data))
-}
-
-// clearFilesystemPrompt removes the filesystem prompt.txt for a session.
-// Called after condensation so subsequent checkpoints start fresh.
-func clearFilesystemPrompt(ctx context.Context, sessionID string) {
- sessionDir := paths.SessionMetadataDirFromSessionID(sessionID)
- sessionDirAbs, err := paths.AbsPath(ctx, sessionDir)
- if err != nil {
- return
- }
- promptPath := filepath.Join(sessionDirAbs, paths.PromptFileName)
- _ = os.Remove(promptPath)
-}
-
-// CondenseSessionByID condenses a session by its ID and cleans up.
-// This is used by "trace doctor" to salvage stuck sessions.
-func (s *ManualCommitStrategy) CondenseSessionByID(ctx context.Context, sessionID string) error {
- logCtx := logging.WithComponent(ctx, "condense-by-id")
-
- // Load session state
- state, err := s.loadSessionState(ctx, sessionID)
- if err != nil {
- return fmt.Errorf("failed to load session state: %w", err)
- }
- if state == nil {
- return fmt.Errorf("session not found: %s", sessionID)
- }
-
- // Open repository
- repo, err := OpenRepository(ctx)
- if err != nil {
- return fmt.Errorf("failed to open repository: %w", err)
- }
-
- // Generate a checkpoint ID
- checkpointID, err := id.Generate()
- if err != nil {
- return fmt.Errorf("failed to generate checkpoint ID: %w", err)
- }
-
- // Check if shadow branch exists (required for condensation)
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- _, refErr := repo.Reference(refName, true)
- hasShadowBranch := refErr == nil
-
- if !hasShadowBranch {
- // No shadow branch means no checkpoint data to condense.
- // Just clean up the state file.
- logging.Info(
- logCtx, "no shadow branch for session, clearing state only",
- slog.String("session_id", sessionID),
- slog.String("shadow_branch", shadowBranchName),
- )
- if err := s.clearSessionState(ctx, sessionID); err != nil {
- return fmt.Errorf("failed to clear session state: %w", err)
- }
- return nil
- }
-
- // Condense the session
- result, err := s.CondenseSession(ctx, repo, checkpointID, state, nil)
- if err != nil {
- return fmt.Errorf("failed to condense session: %w", err)
- }
-
- if result.Skipped {
- // Nothing to condense. Mark fully condensed so trace doctor doesn't
- // keep retrying this empty session on every invocation.
- logging.Info(
- logCtx, "session condensation skipped (no transcript or files), marking fully condensed",
- slog.String("session_id", sessionID),
- )
- state.FullyCondensed = true
- return s.saveSessionState(ctx, state)
- }
-
- logging.Info(
- logCtx, "session condensed by ID",
- slog.String("session_id", sessionID),
- slog.String("checkpoint_id", result.CheckpointID.String()),
- slog.Int("checkpoints_condensed", result.CheckpointsCount),
- )
-
- // Update session state: reset step count and transition to idle
- state.StepCount = 0
- state.CheckpointTranscriptStart = result.TotalTranscriptLines
- state.CompactTranscriptStart += result.CompactTranscriptLines
- state.CheckpointTranscriptSize = int64(len(result.Transcript))
- state.Phase = session.PhaseIdle
- state.LastCheckpointID = checkpointID
- state.LastCheckpointCommitHash = state.BaseCommit
- state.RealignAttributionBase(state.BaseCommit)
- state.PromptAttributions = nil
- state.PendingPromptAttribution = nil
-
- if err := s.saveSessionState(ctx, state); err != nil {
- return fmt.Errorf("failed to save session state: %w", err)
- }
-
- // Clean up shadow branch if no other sessions need it
- if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil {
- logging.Warn(
- logCtx, "failed to clean up shadow branch",
- slog.String("shadow_branch", shadowBranchName),
- slog.String("error", err.Error()),
- )
- // Non-fatal: condensation succeeded, shadow branch cleanup is best-effort
- }
-
- return nil
-}
-
-// CondenseAndMarkFullyCondensed condenses an ENDED session and marks it
-// FullyCondensed in one operation. Used by the session stop hook to eagerly
-// clean up sessions so PostCommit doesn't have to process them.
-//
-// This does NOT call CondenseSessionByID because that method has two behaviors
-// we don't want: (1) it calls clearSessionState when no shadow branch exists
-// (deletes the state file entirely), and (2) it sets Phase = IDLE. Instead,
-// we inline the condensation logic with ENDED-appropriate behavior.
-//
-// Fail-open: if condensation fails, the session is left in its current state
-// and PostCommit will still process it on the next commit.
-func (s *ManualCommitStrategy) CondenseAndMarkFullyCondensed(ctx context.Context, sessionID string) error {
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- state, err := s.loadSessionState(ctx, sessionID)
- if err != nil {
- return fmt.Errorf("failed to load session state: %w", err)
- }
- if state == nil {
- return nil // No state file
- }
-
- // Sessions with FilesTouched must be processed by PostCommit for carry-forward
- // tracking — each user commit that overlaps with tracked files gets its own
- // checkpoint. Eagerly condensing here would prevent that 1:1 linkage.
- if len(state.FilesTouched) > 0 {
- return nil
- }
-
- // Only condense if there's uncondensed data
- if state.StepCount <= 0 {
- // No data and no files — mark FullyCondensed
- state.FullyCondensed = true
- return s.saveSessionState(ctx, state)
- }
-
- // Check if shadow branch exists — required for condensation
- repo, err := OpenRepository(ctx)
- if err != nil {
- logging.Warn(
- logCtx, "eager condense: failed to open repository",
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return nil // fail-open
- }
-
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- _, refErr := repo.Reference(refName, true)
- hasShadowBranch := refErr == nil
-
- if !hasShadowBranch {
- // No shadow branch = no checkpoint data to condense.
- // Unlike CondenseSessionByID, we do NOT delete the state file.
- logging.Info(
- logCtx, "eager condense: no shadow branch",
- slog.String("session_id", sessionID),
- slog.String("shadow_branch", shadowBranchName),
- )
- state.StepCount = 0
- state.FullyCondensed = true // FilesTouched is already empty (checked above)
- return s.saveSessionState(ctx, state)
- }
-
- // Generate checkpoint ID and condense
- checkpointID, err := id.Generate()
- if err != nil {
- logging.Warn(
- logCtx, "eager condense: failed to generate checkpoint ID",
- slog.String("error", err.Error()),
- )
- return nil // fail-open
- }
-
- // Condense with nil committedFiles (include all FilesTouched)
- result, err := s.CondenseSession(ctx, repo, checkpointID, state, nil)
- if err != nil {
- logging.Warn(
- logCtx, "eager condense on session stop failed, PostCommit will retry",
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return nil // fail-open
- }
-
- if result.Skipped {
- // No transcript or files — nothing to condense. Mark fully condensed
- // so PostCommit doesn't keep retrying this empty session.
- logging.Info(
- logCtx, "eager condense skipped (no transcript or files), marking fully condensed",
- slog.String("session_id", sessionID),
- )
- state.FullyCondensed = true
- return s.saveSessionState(ctx, state)
- }
-
- // Update state — keep Phase = ENDED (unlike CondenseSessionByID which sets IDLE)
- state.StepCount = 0
- state.CheckpointTranscriptStart = result.TotalTranscriptLines
- state.CompactTranscriptStart += result.CompactTranscriptLines
- state.LastCheckpointID = checkpointID
- state.LastCheckpointCommitHash = state.BaseCommit
- state.RealignAttributionBase(state.BaseCommit)
- state.PromptAttributions = nil
- state.PendingPromptAttribution = nil
- state.FullyCondensed = true // FilesTouched is already empty (checked above)
- // Phase stays ENDED — do NOT set to IDLE
-
- logging.Info(
- logCtx, "eager condense on session stop succeeded",
- slog.String("session_id", sessionID),
- slog.String("checkpoint_id", result.CheckpointID.String()),
- )
-
- if err := s.saveSessionState(ctx, state); err != nil {
- return fmt.Errorf("failed to save session state: %w", err)
- }
-
- // Clean up shadow branch
- if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil {
- logging.Warn(
- logCtx, "eager condense: failed to clean up shadow branch",
- slog.String("shadow_branch", shadowBranchName),
- slog.String("error", err.Error()),
- )
- }
-
- return nil
-}
-
-// cleanupShadowBranchIfUnused deletes a shadow branch if no other active sessions reference it.
-func (s *ManualCommitStrategy) cleanupShadowBranchIfUnused(ctx context.Context, _ *git.Repository, shadowBranchName, excludeSessionID string) error {
- // List all session states to check if any other session uses this shadow branch
- allStates, err := s.listAllSessionStates(ctx)
- if err != nil {
- return fmt.Errorf("failed to list session states: %w", err)
- }
-
- for _, state := range allStates {
- if state.SessionID == excludeSessionID {
- continue
- }
- otherShadow := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- if otherShadow == shadowBranchName && state.StepCount > 0 {
- // Another session still needs this shadow branch
- return nil
- }
- }
-
- // No other sessions need it, delete the shadow branch via CLI
- // (go-git v5's RemoveReference doesn't persist with packed refs/worktrees)
- if err := DeleteBranchCLI(ctx, shadowBranchName); err != nil {
- // Branch already gone is not an error
- if errors.Is(err, ErrBranchNotFound) {
- return nil
- }
- return fmt.Errorf("failed to remove shadow branch: %w", err)
- }
- return nil
-}
-
-// compactTranscriptForV2 produces the Trace Transcript Format (transcript.jsonl)
-// from a redacted agent transcript. Returns nil if compaction cannot be performed
-// (nil agent, empty transcript, or compaction error) —
-// callers treat nil as "skip writing transcript.jsonl to /main".
-func compactTranscriptForV2(ctx context.Context, ag agent.Agent, transcript redact.RedactedBytes, checkpointTranscriptStart int) []byte {
- if ag == nil || transcript.Len() == 0 {
- return nil
- }
-
- compacted, err := compact.Compact(transcript, compact.MetadataFields{
- Agent: string(ag.Name()),
- CLIVersion: versioninfo.Version,
- StartLine: checkpointTranscriptStart,
- })
- if err != nil {
- logging.Warn(
- ctx, "compact transcript generation failed, skipping transcript.jsonl on /main",
- slog.String("agent", string(ag.Name())),
- slog.String("error", err.Error()),
- )
- return nil
- }
- return compacted
-}
-
-// countCompactLines returns line count for compact transcript JSONL.
-func countCompactLines(compactTranscript []byte) int {
- return bytes.Count(compactTranscript, []byte{'\n'})
-}
-
-// computeCompactTranscriptStart chooses the compact transcript start line offset
-// for v2 /main metadata.
-//
-// Preferred source is session state CompactTranscriptStart. For legacy sessions
-// that have only full-transcript offsets persisted, this recalculates the compact
-// offset from transcript bytes when possible. On any failure, returns 0 (fail-open).
-func computeCompactTranscriptStart(ctx context.Context, ag agent.Agent, state *SessionState, transcript []byte, scopedCompact []byte) int {
- if state.CompactTranscriptStart > 0 {
- return state.CompactTranscriptStart
- }
- if state.CheckpointTranscriptStart == 0 || ag == nil || len(transcript) == 0 || len(scopedCompact) == 0 {
- return 0
- }
-
- // transcript is already redacted (passed as .Bytes() from RedactedBytes).
- fullCompacted, err := compact.Compact(redact.AlreadyRedacted(transcript), compact.MetadataFields{
- Agent: string(ag.Name()),
- CLIVersion: versioninfo.Version,
- StartLine: 0,
- })
- if err != nil || len(fullCompacted) == 0 {
- logging.Warn(
- ctx, "failed to recalculate compact transcript start, using 0",
- slog.String("session_id", state.SessionID),
- )
- return 0
- }
-
- fullLines := countCompactLines(fullCompacted)
- scopedLines := countCompactLines(scopedCompact)
- offset := fullLines - scopedLines
- if offset < 0 {
- return 0
- }
- return offset
-}
-
-// writeCommittedV2 writes checkpoint data to v2 refs unconditionally.
-// Callers decide whether to propagate or swallow the error (v2-only vs dual-write).
-func writeCommittedV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error {
- v2URL, err := remote.FetchURL(ctx)
- if err != nil {
- logging.Debug(
- ctx, "manual-commit condensation: using origin for v2 write fetch remote",
- slog.String("error", err.Error()),
- )
- v2URL = originRemote
- }
- v2Store := cpkg.NewV2GitStore(repo, v2URL)
- if err := v2Store.WriteCommitted(ctx, opts); err != nil {
- return fmt.Errorf("v2 write committed: %w", err)
- }
- return nil
-}
-
-// writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2
-// is enabled. Failures are logged as warnings — in dual-write mode v2 writes are
-// best-effort and must not block the v1 path.
-func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) {
- if !settings.IsCheckpointsV2Enabled(ctx) {
- return
- }
- if err := writeCommittedV2(ctx, repo, opts); err != nil {
- logging.Warn(
- ctx, "v2 dual-write failed",
- slog.String("checkpoint_id", opts.CheckpointID.String()),
- slog.String("error", err.Error()),
- )
- }
-}
-
-// writeTaskMetadataV2IfEnabled copies task metadata trees from the shadow branch
-// to v2 /full/current when dual-write is enabled.
-//
-// This mirrors migrate's task backfill behavior for newly created checkpoints so
-// task rewind artifacts (tasks//...) are available in v2 immediately,
-// not only after running `trace migrate --checkpoints v2`.
-func writeTaskMetadataV2IfEnabled(
- ctx context.Context,
- repo *git.Repository,
- checkpointID id.CheckpointID,
- sessionID string,
- shadowRef *plumbing.Reference,
-) {
- if !settings.IsCheckpointsV2Enabled(ctx) || shadowRef == nil {
- return
- }
-
- shadowCommit, err := repo.CommitObject(shadowRef.Hash())
- if err != nil {
- logging.Warn(
- ctx, "v2 dual-write task metadata copy skipped: failed to read shadow commit",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return
- }
-
- shadowTree, err := shadowCommit.Tree()
- if err != nil {
- logging.Warn(
- ctx, "v2 dual-write task metadata copy skipped: failed to read shadow tree",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return
- }
-
- tasksPath := paths.SessionMetadataDirFromSessionID(sessionID) + "/tasks"
- tasksTree, err := shadowTree.Tree(tasksPath)
- if err != nil {
- return
- }
-
- v2URL, err := remote.FetchURL(ctx)
- if err != nil {
- logging.Debug(
- ctx, "manual-commit condensation: using origin for v2 task metadata fetch remote",
- slog.String("error", err.Error()),
- )
- v2URL = originRemote
- }
- v2Store := cpkg.NewV2GitStore(repo, v2URL)
- sessionIndex, err := resolveV2SessionIndexForCheckpoint(repo, checkpointID, sessionID)
- if err != nil {
- logging.Warn(
- ctx, "v2 dual-write task metadata copy skipped: failed to resolve session index",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- return
- }
-
- if err := spliceTaskTreeToV2FullCurrent(ctx, repo, v2Store, checkpointID, sessionIndex, tasksTree.Hash); err != nil {
- logging.Warn(
- ctx, "v2 dual-write task metadata copy failed",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("session_id", sessionID),
- slog.String("error", err.Error()),
- )
- }
-}
-
-func resolveV2SessionIndexForCheckpoint(repo *git.Repository, checkpointID id.CheckpointID, sessionID string) (int, error) {
- v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- if err != nil {
- return 0, fmt.Errorf("read v2 /main ref: %w", err)
- }
- v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
- if err != nil {
- return 0, fmt.Errorf("read v2 /main commit: %w", err)
- }
- v2MainTree, err := v2MainCommit.Tree()
- if err != nil {
- return 0, fmt.Errorf("read v2 /main tree: %w", err)
- }
-
- checkpointTree, err := v2MainTree.Tree(checkpointID.Path())
- if err != nil {
- return 0, fmt.Errorf("read checkpoint subtree on v2 /main: %w", err)
- }
-
- metadataFile, err := checkpointTree.File(paths.MetadataFileName)
- if err != nil {
- return 0, fmt.Errorf("read checkpoint summary metadata: %w", err)
- }
- metadataContent, err := metadataFile.Contents()
- if err != nil {
- return 0, fmt.Errorf("read checkpoint summary contents: %w", err)
- }
-
- var summary cpkg.CheckpointSummary
- if err := json.Unmarshal([]byte(metadataContent), &summary); err != nil {
- return 0, fmt.Errorf("parse checkpoint summary metadata: %w", err)
- }
-
- for i := range len(summary.Sessions) {
- sessionTree, err := checkpointTree.Tree(strconv.Itoa(i))
- if err != nil {
- continue
- }
- sessionMetadataFile, err := sessionTree.File(paths.MetadataFileName)
- if err != nil {
- continue
- }
- sessionMetadataContent, err := sessionMetadataFile.Contents()
- if err != nil {
- continue
- }
-
- var sessionMeta cpkg.CommittedMetadata
- if err := json.Unmarshal([]byte(sessionMetadataContent), &sessionMeta); err != nil {
- continue
- }
- if sessionMeta.SessionID == sessionID {
- return i, nil
- }
- }
-
- return 0, fmt.Errorf("session %q not found in v2 checkpoint %s", sessionID, checkpointID)
-}
-
-func spliceTaskTreeToV2FullCurrent(
- ctx context.Context,
- repo *git.Repository,
- v2Store *cpkg.V2GitStore,
- checkpointID id.CheckpointID,
- sessionIndex int,
- tasksTreeHash plumbing.Hash,
-) error {
- refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
- parentHash, rootTreeHash, err := v2Store.GetRefState(refName)
- if err != nil {
- return fmt.Errorf("get v2 /full/current ref state: %w", err)
- }
- incomingTasksTree, err := repo.TreeObject(tasksTreeHash)
- if err != nil {
- return fmt.Errorf("read task tree: %w", err)
- }
-
- shardPrefix := string(checkpointID[:2])
- shardSuffix := string(checkpointID[2:])
- sessionDir := strconv.Itoa(sessionIndex)
-
- newRootHash, err := cpkg.UpdateSubtree(
- repo, rootTreeHash,
- []string{shardPrefix, shardSuffix, sessionDir, "tasks"},
- incomingTasksTree.Entries,
- cpkg.UpdateSubtreeOptions{MergeMode: cpkg.MergeKeepExisting},
- )
- if err != nil {
- return fmt.Errorf("splice task tree into v2 /full/current: %w", err)
- }
-
- authorName, authorEmail := cpkg.GetGitAuthorFromRepo(repo)
- commitHash, err := cpkg.CreateCommit(ctx, repo, newRootHash, parentHash,
- fmt.Sprintf("Checkpoint: %s (task metadata)\n", checkpointID),
- authorName, authorEmail)
- if err != nil {
- return fmt.Errorf("create v2 task metadata commit: %w", err)
- }
-
- if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)); err != nil {
- return fmt.Errorf("update v2 /full/current ref: %w", err)
- }
-
- return nil
-}
diff --git a/cli/strategy/manual_commit_condensation_2.go b/cli/strategy/manual_commit_condensation_2.go
new file mode 100644
index 0000000..a8ee538
--- /dev/null
+++ b/cli/strategy/manual_commit_condensation_2.go
@@ -0,0 +1,821 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid"
+ "github.com/GrayCodeAI/trace/cli/agent/geminicli"
+ "github.com/GrayCodeAI/trace/cli/agent/opencode"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/textutil"
+ "github.com/GrayCodeAI/trace/cli/transcript"
+ "github.com/GrayCodeAI/trace/cli/transcript/compact"
+ "github.com/GrayCodeAI/trace/cli/versioninfo"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func calculateSessionAttributions(ctx context.Context, repo *git.Repository, shadowRef *plumbing.Reference, sessionData *ExtractedSessionData, state *SessionState, opts ...attributionOpts) *cpkg.InitialAttribution {
+ // Calculate initial attribution using accumulated prompt attribution data.
+ // This uses user edits captured at each prompt start (before agent works),
+ // plus any user edits after the final checkpoint (shadow → head).
+ //
+ // When shadowRef is nil (agent committed mid-turn before SaveStep),
+ // HEAD is used as the shadow tree. This is correct because the agent's
+ // commit IS HEAD — there are no user edits between agent work and commit.
+ logCtx := logging.WithComponent(ctx, "attribution")
+
+ var o attributionOpts
+ if len(opts) > 0 {
+ o = opts[0]
+ }
+
+ headTree := o.headTree
+ if headTree == nil {
+ headRef, headErr := repo.Head()
+ if headErr != nil {
+ logging.Debug(logCtx, "attribution skipped: failed to get HEAD",
+ slog.String("error", headErr.Error()))
+ return nil
+ }
+
+ headCommit, commitErr := repo.CommitObject(headRef.Hash())
+ if commitErr != nil {
+ logging.Debug(logCtx, "attribution skipped: failed to get HEAD commit",
+ slog.String("error", commitErr.Error()))
+ return nil
+ }
+
+ var treeErr error
+ headTree, treeErr = headCommit.Tree()
+ if treeErr != nil {
+ logging.Debug(logCtx, "attribution skipped: failed to get HEAD tree",
+ slog.String("error", treeErr.Error()))
+ return nil
+ }
+ }
+
+ // Get shadow tree: from pre-resolved cache, shadow branch, or HEAD (agent committed directly).
+ shadowTree := o.shadowTree
+ if shadowTree == nil {
+ if shadowRef != nil {
+ shadowCommit, shadowErr := repo.CommitObject(shadowRef.Hash())
+ if shadowErr != nil {
+ logging.Debug(logCtx, "attribution skipped: failed to get shadow commit",
+ slog.String("error", shadowErr.Error()),
+ slog.String("shadow_ref", shadowRef.Hash().String()))
+ return nil
+ }
+ var shadowTreeErr error
+ shadowTree, shadowTreeErr = shadowCommit.Tree()
+ if shadowTreeErr != nil {
+ logging.Debug(logCtx, "attribution skipped: failed to get shadow tree",
+ slog.String("error", shadowTreeErr.Error()))
+ return nil
+ }
+ } else {
+ // No shadow branch: agent committed mid-turn. Use HEAD as shadow
+ // because the agent's work is the commit itself.
+ logging.Debug(logCtx, "attribution: using HEAD as shadow (no shadow branch)")
+ shadowTree = headTree
+ }
+ }
+
+ // Get base tree (state before session started)
+ var baseTree *object.Tree
+ attrBase := state.AttributionBaseCommit
+ if attrBase == "" {
+ attrBase = state.BaseCommit // backward compat
+ }
+ if baseCommit, baseErr := repo.CommitObject(plumbing.NewHash(attrBase)); baseErr == nil {
+ if tree, baseTErr := baseCommit.Tree(); baseTErr == nil {
+ baseTree = tree
+ } else {
+ logging.Debug(logCtx, "attribution: base tree unavailable",
+ slog.String("error", baseTErr.Error()))
+ }
+ } else {
+ logging.Debug(logCtx, "attribution: base commit unavailable",
+ slog.String("error", baseErr.Error()),
+ slog.String("attribution_base", attrBase))
+ }
+
+ // Include PendingPromptAttribution if it was never moved to PromptAttributions.
+ // This happens when an agent commits mid-turn without calling SaveStep (e.g., Codex).
+ // PendingPromptAttribution is set during UserPromptSubmit but only moved to
+ // PromptAttributions during SaveStep. Without this, mid-turn commits have no PA
+ // data and pre-session worktree dirt cannot be identified for baseline exclusion.
+ promptAttrs := state.PromptAttributions
+ if state.PendingPromptAttribution != nil {
+ promptAttrs = append(promptAttrs, *state.PendingPromptAttribution)
+ }
+
+ // Log accumulated prompt attributions for debugging
+ var totalUserAdded, totalUserRemoved int
+ for i, pa := range promptAttrs {
+ totalUserAdded += pa.UserLinesAdded
+ totalUserRemoved += pa.UserLinesRemoved
+ logging.Debug(logCtx, "prompt attribution data",
+ slog.Int("checkpoint", pa.CheckpointNumber),
+ slog.Int("user_added", pa.UserLinesAdded),
+ slog.Int("user_removed", pa.UserLinesRemoved),
+ slog.Int("agent_added", pa.AgentLinesAdded),
+ slog.Int("agent_removed", pa.AgentLinesRemoved),
+ slog.Int("index", i))
+ }
+
+ attribution := CalculateAttributionWithAccumulated(ctx, AttributionParams{
+ BaseTree: baseTree,
+ ShadowTree: shadowTree,
+ HeadTree: headTree,
+ ParentTree: o.parentTree,
+ FilesTouched: sessionData.FilesTouched,
+ PromptAttributions: promptAttrs,
+ RepoDir: o.repoDir,
+ ParentCommitHash: o.parentCommitHash,
+ AttributionBaseCommit: attrBase,
+ HeadCommitHash: o.headCommitHash,
+ AllAgentFiles: o.allAgentFiles,
+ })
+
+ if attribution != nil {
+ logging.Info(logCtx, "attribution calculated",
+ slog.Int("agent_lines", attribution.AgentLines),
+ slog.Int("human_added", attribution.HumanAdded),
+ slog.Int("human_modified", attribution.HumanModified),
+ slog.Int("human_removed", attribution.HumanRemoved),
+ slog.Int("total_committed", attribution.TotalCommitted),
+ slog.Float64("agent_percentage", attribution.AgentPercentage),
+ slog.Int("accumulated_user_added", totalUserAdded),
+ slog.Int("accumulated_user_removed", totalUserRemoved),
+ slog.Int("files_touched", len(sessionData.FilesTouched)))
+ }
+
+ return attribution
+}
+
+// committedFilesExcludingMetadata returns committed files with CLI metadata paths filtered out.
+// `.trace/` files are created by `trace enable`, not by the agent, and should not be
+// attributed as agent work when used as a fallback for sessions with no FilesTouched.
+func committedFilesExcludingMetadata(committedFiles map[string]struct{}) []string {
+ result := make([]string, 0, len(committedFiles))
+ for f := range committedFiles {
+ if strings.HasPrefix(f, ".trace/") || strings.HasPrefix(f, paths.TraceMetadataDir+"/") {
+ continue
+ }
+ result = append(result, f)
+ }
+ slices.Sort(result)
+ return result
+}
+
+// extractSessionData extracts session data from the shadow branch.
+// filesTouched is the list of files tracked during the session (from SessionState.FilesTouched).
+// agentType identifies the agent (e.g., "Gemini CLI", "Claude Code") to determine transcript format.
+// liveTranscriptPath, when non-empty and readable, is preferred over the shadow branch copy.
+// This handles the case where SaveStep was skipped (no code changes) but the transcript
+// continued growing — the shadow branch copy would be stale.
+// checkpointTranscriptStart is the line offset (Claude) or message index (Gemini) where the current checkpoint began.
+func (s *ManualCommitStrategy) extractSessionData(ctx context.Context, repo *git.Repository, shadowRef plumbing.Hash, sessionID string, filesTouched []string, agentType types.AgentType, liveTranscriptPath string, checkpointTranscriptStart int, isActive bool) (*ExtractedSessionData, error) {
+ ag, _ := agent.GetByAgentType(agentType) //nolint:errcheck // ag may be nil for unknown agent types; callers use type assertions so nil is safe
+ commit, err := repo.CommitObject(shadowRef)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit object: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commit tree: %w", err)
+ }
+
+ data := &ExtractedSessionData{}
+ // sessionID is already an "trace session ID" (with date prefix)
+ metadataDir := paths.SessionMetadataDirFromSessionID(sessionID)
+
+ // Extract transcript — prefer the live file when available, fall back to shadow branch.
+ // The shadow branch copy may be stale if the last turn ended without code changes
+ // (SaveStep is only called when there are file modifications).
+ var fullTranscript string
+ if liveTranscriptPath != "" {
+ // Ensure transcript file exists (OpenCode creates it lazily via `opencode export`).
+ // Only wait for flush when the session is active — for idle/ended sessions the
+ // transcript is already fully flushed (the Stop hook completed the flush).
+ if isActive {
+ prepareTranscriptIfNeeded(ctx, ag, liveTranscriptPath)
+ }
+ if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 { //nolint:gosec // path from session state
+ fullTranscript = string(liveData)
+ }
+ }
+ if fullTranscript == "" {
+ // Fall back to shadow branch copy
+ if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileName); fileErr == nil {
+ if content, contentErr := file.Contents(); contentErr == nil {
+ fullTranscript = content
+ }
+ } else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil {
+ if content, contentErr := file.Contents(); contentErr == nil {
+ fullTranscript = content
+ }
+ }
+ }
+
+ // Process transcript based on agent type
+ if fullTranscript != "" {
+ data.Transcript = []byte(fullTranscript)
+ data.FullTranscriptLines = countTranscriptItems(agentType, fullTranscript)
+ // Read prompts from shadow branch tree (source of truth after SaveStep)
+ if file, fileErr := tree.File(metadataDir + "/" + paths.PromptFileName); fileErr == nil {
+ if content, contentErr := file.Contents(); contentErr == nil && content != "" {
+ data.Prompts = splitPromptContent(content)
+ }
+ }
+ // Filesystem fallback (written at turn start, covers mid-turn commits)
+ if len(data.Prompts) == 0 {
+ data.Prompts = readPromptsFromFilesystem(ctx, sessionID)
+ }
+ }
+
+ // Use tracked files from session state (not all files in tree)
+ data.FilesTouched = filesTouched
+
+ // Calculate token usage from the extracted transcript portion
+ if len(data.Transcript) > 0 {
+ // Derive subagents directory from the live transcript path when available.
+ // Pattern: //subagents (same as manual_commit_hooks.go)
+ var subagentsDir string
+ if liveTranscriptPath != "" {
+ subagentsDir = filepath.Join(filepath.Dir(liveTranscriptPath), sessionID, "subagents")
+ }
+ data.TokenUsage = agent.CalculateTokenUsage(ctx, ag, data.Transcript, checkpointTranscriptStart, subagentsDir)
+ }
+
+ return data, nil
+}
+
+// extractSessionDataFromLiveTranscript extracts session data directly from the live transcript file.
+// This is used for mid-session commits where no shadow branch exists yet.
+func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context.Context, state *SessionState) (*ExtractedSessionData, error) {
+ data := &ExtractedSessionData{}
+
+ ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; callers use type assertions so nil is safe
+
+ // Resolve the transcript path (handles agents that relocate mid-session).
+ transcriptPath, resolveErr := resolveTranscriptPath(state)
+ if resolveErr != nil {
+ return nil, resolveErr
+ }
+
+ liveData, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath
+ if err != nil {
+ return nil, fmt.Errorf("failed to read live transcript: %w", err)
+ }
+
+ if len(liveData) == 0 {
+ return nil, errors.New("live transcript is empty")
+ }
+
+ fullTranscript := string(liveData)
+ data.Transcript = liveData
+ data.FullTranscriptLines = countTranscriptItems(state.AgentType, fullTranscript)
+ data.Prompts = readPromptsFromFilesystem(ctx, state.SessionID)
+
+ // Resolve files touched: prefers hook-populated state, falls back to transcript extraction
+ data.FilesTouched = s.resolveFilesTouched(ctx, state)
+
+ // Calculate token usage from the extracted transcript portion
+ if len(data.Transcript) > 0 {
+ // Derive subagents directory from the transcript path when available.
+ // Pattern: //subagents (same as manual_commit_hooks.go)
+ var subagentsDir string
+ if state.TranscriptPath != "" {
+ subagentsDir = filepath.Join(filepath.Dir(state.TranscriptPath), state.SessionID, "subagents")
+ }
+ data.TokenUsage = agent.CalculateTokenUsage(ctx, ag, data.Transcript, state.CheckpointTranscriptStart, subagentsDir)
+ }
+
+ return data, nil
+}
+
+// countTranscriptItems counts lines (JSONL) or messages (JSON) in a transcript.
+// For Claude Code and JSONL-based agents, this counts lines.
+// For Gemini CLI, OpenCode, and JSON-based agents, this counts messages.
+// Returns 0 if the content is empty or malformed.
+func countTranscriptItems(agentType types.AgentType, content string) int {
+ if content == "" {
+ return 0
+ }
+
+ // OpenCode uses export JSON format with {"info": {...}, "messages": [...]}
+ if agentType == agent.AgentTypeOpenCode {
+ session, err := opencode.ParseExportSession([]byte(content))
+ if err == nil && session != nil {
+ return len(session.Messages)
+ }
+ return 0
+ }
+
+ // Try Gemini format first if agentType is Gemini, or as fallback if Unknown
+ if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown {
+ transcript, err := geminicli.ParseTranscript([]byte(content))
+ if err == nil && transcript != nil && len(transcript.Messages) > 0 {
+ return len(transcript.Messages)
+ }
+ // If agentType is explicitly Gemini but parsing failed, return 0
+ if agentType == agent.AgentTypeGemini {
+ return 0
+ }
+ // Otherwise fall through to JSONL parsing for Unknown type
+ }
+
+ // Claude Code and other JSONL-based agents
+ allLines := strings.Split(content, "\n")
+ // Trim trailing empty lines (from final \n in JSONL)
+ for len(allLines) > 0 && strings.TrimSpace(allLines[len(allLines)-1]) == "" {
+ allLines = allLines[:len(allLines)-1]
+ }
+ return len(allLines)
+}
+
+// extractUserPrompts extracts all user prompts from transcript content.
+// Returns prompts with IDE context tags stripped (e.g., ).
+func extractUserPrompts(agentType types.AgentType, content string) []string {
+ if content == "" {
+ return nil
+ }
+
+ // Droid has its own envelope format — use its parser to normalize first
+ if agentType == agent.AgentTypeFactoryAIDroid {
+ lines, _, err := factoryaidroid.ParseDroidTranscriptFromBytes([]byte(content), 0)
+ if err != nil {
+ return nil
+ }
+ var prompts []string
+ for _, line := range lines {
+ if line.Type != transcript.TypeUser {
+ continue
+ }
+ if text := transcript.ExtractUserContent(line.Message); text != "" {
+ if stripped := textutil.StripIDEContextTags(text); stripped != "" {
+ prompts = append(prompts, stripped)
+ }
+ }
+ }
+ return prompts
+ }
+
+ // OpenCode uses JSONL with a different per-line schema than Claude Code
+ if agentType == agent.AgentTypeOpenCode {
+ prompts, err := opencode.ExtractAllUserPrompts([]byte(content))
+ if err == nil && len(prompts) > 0 {
+ cleaned := make([]string, 0, len(prompts))
+ for _, prompt := range prompts {
+ if stripped := textutil.StripIDEContextTags(prompt); stripped != "" {
+ cleaned = append(cleaned, stripped)
+ }
+ }
+ return cleaned
+ }
+ return nil
+ }
+
+ // Try Gemini format first if agentType is Gemini, or as fallback if Unknown
+ if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown {
+ prompts, err := geminicli.ExtractAllUserPrompts([]byte(content))
+ if err == nil && len(prompts) > 0 {
+ // Strip IDE context tags for consistency with Claude Code handling
+ cleaned := make([]string, 0, len(prompts))
+ for _, prompt := range prompts {
+ if stripped := textutil.StripIDEContextTags(prompt); stripped != "" {
+ cleaned = append(cleaned, stripped)
+ }
+ }
+ return cleaned
+ }
+ // If agentType is explicitly Gemini but parsing failed, return nil
+ if agentType == agent.AgentTypeGemini {
+ return nil
+ }
+ // Otherwise fall through to JSONL parsing for Unknown type
+ }
+
+ // Claude Code and other JSONL-based agents
+ return extractUserPromptsFromLines(strings.Split(content, "\n"))
+}
+
+// extractUserPromptsFromLines extracts user prompts from JSONL transcript lines.
+// IDE-injected context tags (like ) are stripped from the results.
+func extractUserPromptsFromLines(lines []string) []string {
+ var prompts []string
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ var entry map[string]interface{}
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ continue
+ }
+
+ // Check for user message:
+ // - Claude Code uses "type": "human" or "type": "user"
+ // - Cursor uses "role": "user"
+ msgType, _ := entry["type"].(string) //nolint:errcheck // type assertion on interface{} from JSON
+ msgRole, _ := entry["role"].(string) //nolint:errcheck // type assertion on interface{} from JSON
+ isUser := msgType == "human" || msgType == "user" || msgRole == "user"
+ if !isUser {
+ continue
+ }
+
+ // Extract message content
+ message, ok := entry["message"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Handle string content
+ if content, ok := message["content"].(string); ok && content != "" {
+ cleaned := textutil.StripIDEContextTags(content)
+ if cleaned != "" {
+ prompts = append(prompts, cleaned)
+ }
+ continue
+ }
+
+ // Handle array content (e.g., multiple text blocks from VSCode)
+ if arr, ok := message["content"].([]interface{}); ok {
+ var texts []string
+ for _, item := range arr {
+ if m, ok := item.(map[string]interface{}); ok {
+ if m["type"] == "text" {
+ if text, ok := m["text"].(string); ok {
+ texts = append(texts, text)
+ }
+ }
+ }
+ }
+ if len(texts) > 0 {
+ cleaned := textutil.StripIDEContextTags(strings.Join(texts, "\n\n"))
+ if cleaned != "" {
+ prompts = append(prompts, cleaned)
+ }
+ }
+ }
+ }
+ return prompts
+}
+
+// splitPromptContent splits prompt.txt content on the "\n\n---\n\n" separator.
+// Returns nil if content is empty.
+func splitPromptContent(content string) []string {
+ if content == "" {
+ return nil
+ }
+ parts := strings.Split(content, "\n\n---\n\n")
+ var result []string
+ for _, p := range parts {
+ trimmed := strings.TrimSpace(p)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+}
+
+// readPromptsFromFilesystem reads prompt.txt from the filesystem session metadata directory.
+// This file is written at turn start and updated at each SaveStep, providing prompt data
+// even for mid-turn commits where the shadow branch may not have been updated.
+func readPromptsFromFilesystem(ctx context.Context, sessionID string) []string {
+ sessionDir := paths.SessionMetadataDirFromSessionID(sessionID)
+ sessionDirAbs, err := paths.AbsPath(ctx, sessionDir)
+ if err != nil {
+ return nil
+ }
+ data, err := os.ReadFile(filepath.Join(sessionDirAbs, paths.PromptFileName)) //nolint:gosec // path from session ID
+ if err != nil || len(data) == 0 {
+ return nil
+ }
+ return splitPromptContent(string(data))
+}
+
+// clearFilesystemPrompt removes the filesystem prompt.txt for a session.
+// Called after condensation so subsequent checkpoints start fresh.
+func clearFilesystemPrompt(ctx context.Context, sessionID string) {
+ sessionDir := paths.SessionMetadataDirFromSessionID(sessionID)
+ sessionDirAbs, err := paths.AbsPath(ctx, sessionDir)
+ if err != nil {
+ return
+ }
+ promptPath := filepath.Join(sessionDirAbs, paths.PromptFileName)
+ _ = os.Remove(promptPath)
+}
+
+// CondenseSessionByID condenses a session by its ID and cleans up.
+// This is used by "trace doctor" to salvage stuck sessions.
+func (s *ManualCommitStrategy) CondenseSessionByID(ctx context.Context, sessionID string) error {
+ logCtx := logging.WithComponent(ctx, "condense-by-id")
+
+ // Load session state
+ state, err := s.loadSessionState(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("failed to load session state: %w", err)
+ }
+ if state == nil {
+ return fmt.Errorf("session not found: %s", sessionID)
+ }
+
+ // Open repository
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to open repository: %w", err)
+ }
+
+ // Generate a checkpoint ID
+ checkpointID, err := id.Generate()
+ if err != nil {
+ return fmt.Errorf("failed to generate checkpoint ID: %w", err)
+ }
+
+ // Check if shadow branch exists (required for condensation)
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ _, refErr := repo.Reference(refName, true)
+ hasShadowBranch := refErr == nil
+
+ if !hasShadowBranch {
+ // No shadow branch means no checkpoint data to condense.
+ // Just clean up the state file.
+ logging.Info(
+ logCtx, "no shadow branch for session, clearing state only",
+ slog.String("session_id", sessionID),
+ slog.String("shadow_branch", shadowBranchName),
+ )
+ if err := s.clearSessionState(ctx, sessionID); err != nil {
+ return fmt.Errorf("failed to clear session state: %w", err)
+ }
+ return nil
+ }
+
+ // Condense the session
+ result, err := s.CondenseSession(ctx, repo, checkpointID, state, nil)
+ if err != nil {
+ return fmt.Errorf("failed to condense session: %w", err)
+ }
+
+ if result.Skipped {
+ // Nothing to condense. Mark fully condensed so trace doctor doesn't
+ // keep retrying this empty session on every invocation.
+ logging.Info(
+ logCtx, "session condensation skipped (no transcript or files), marking fully condensed",
+ slog.String("session_id", sessionID),
+ )
+ state.FullyCondensed = true
+ return s.saveSessionState(ctx, state)
+ }
+
+ logging.Info(
+ logCtx, "session condensed by ID",
+ slog.String("session_id", sessionID),
+ slog.String("checkpoint_id", result.CheckpointID.String()),
+ slog.Int("checkpoints_condensed", result.CheckpointsCount),
+ )
+
+ // Update session state: reset step count and transition to idle
+ state.StepCount = 0
+ state.CheckpointTranscriptStart = result.TotalTranscriptLines
+ state.CompactTranscriptStart += result.CompactTranscriptLines
+ state.CheckpointTranscriptSize = int64(len(result.Transcript))
+ state.Phase = session.PhaseIdle
+ state.LastCheckpointID = checkpointID
+ state.LastCheckpointCommitHash = state.BaseCommit
+ state.RealignAttributionBase(state.BaseCommit)
+ state.PromptAttributions = nil
+ state.PendingPromptAttribution = nil
+
+ if err := s.saveSessionState(ctx, state); err != nil {
+ return fmt.Errorf("failed to save session state: %w", err)
+ }
+
+ // Clean up shadow branch if no other sessions need it
+ if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil {
+ logging.Warn(
+ logCtx, "failed to clean up shadow branch",
+ slog.String("shadow_branch", shadowBranchName),
+ slog.String("error", err.Error()),
+ )
+ // Non-fatal: condensation succeeded, shadow branch cleanup is best-effort
+ }
+
+ return nil
+}
+
+// CondenseAndMarkFullyCondensed condenses an ENDED session and marks it
+// FullyCondensed in one operation. Used by the session stop hook to eagerly
+// clean up sessions so PostCommit doesn't have to process them.
+//
+// This does NOT call CondenseSessionByID because that method has two behaviors
+// we don't want: (1) it calls clearSessionState when no shadow branch exists
+// (deletes the state file entirely), and (2) it sets Phase = IDLE. Instead,
+// we inline the condensation logic with ENDED-appropriate behavior.
+//
+// Fail-open: if condensation fails, the session is left in its current state
+// and PostCommit will still process it on the next commit.
+func (s *ManualCommitStrategy) CondenseAndMarkFullyCondensed(ctx context.Context, sessionID string) error {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ state, err := s.loadSessionState(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("failed to load session state: %w", err)
+ }
+ if state == nil {
+ return nil // No state file
+ }
+
+ // Sessions with FilesTouched must be processed by PostCommit for carry-forward
+ // tracking — each user commit that overlaps with tracked files gets its own
+ // checkpoint. Eagerly condensing here would prevent that 1:1 linkage.
+ if len(state.FilesTouched) > 0 {
+ return nil
+ }
+
+ // Only condense if there's uncondensed data
+ if state.StepCount <= 0 {
+ // No data and no files — mark FullyCondensed
+ state.FullyCondensed = true
+ return s.saveSessionState(ctx, state)
+ }
+
+ // Check if shadow branch exists — required for condensation
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ logging.Warn(
+ logCtx, "eager condense: failed to open repository",
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return nil // fail-open
+ }
+
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ _, refErr := repo.Reference(refName, true)
+ hasShadowBranch := refErr == nil
+
+ if !hasShadowBranch {
+ // No shadow branch = no checkpoint data to condense.
+ // Unlike CondenseSessionByID, we do NOT delete the state file.
+ logging.Info(
+ logCtx, "eager condense: no shadow branch",
+ slog.String("session_id", sessionID),
+ slog.String("shadow_branch", shadowBranchName),
+ )
+ state.StepCount = 0
+ state.FullyCondensed = true // FilesTouched is already empty (checked above)
+ return s.saveSessionState(ctx, state)
+ }
+
+ // Generate checkpoint ID and condense
+ checkpointID, err := id.Generate()
+ if err != nil {
+ logging.Warn(
+ logCtx, "eager condense: failed to generate checkpoint ID",
+ slog.String("error", err.Error()),
+ )
+ return nil // fail-open
+ }
+
+ // Condense with nil committedFiles (include all FilesTouched)
+ result, err := s.CondenseSession(ctx, repo, checkpointID, state, nil)
+ if err != nil {
+ logging.Warn(
+ logCtx, "eager condense on session stop failed, PostCommit will retry",
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return nil // fail-open
+ }
+
+ if result.Skipped {
+ // No transcript or files — nothing to condense. Mark fully condensed
+ // so PostCommit doesn't keep retrying this empty session.
+ logging.Info(
+ logCtx, "eager condense skipped (no transcript or files), marking fully condensed",
+ slog.String("session_id", sessionID),
+ )
+ state.FullyCondensed = true
+ return s.saveSessionState(ctx, state)
+ }
+
+ // Update state — keep Phase = ENDED (unlike CondenseSessionByID which sets IDLE)
+ state.StepCount = 0
+ state.CheckpointTranscriptStart = result.TotalTranscriptLines
+ state.CompactTranscriptStart += result.CompactTranscriptLines
+ state.LastCheckpointID = checkpointID
+ state.LastCheckpointCommitHash = state.BaseCommit
+ state.RealignAttributionBase(state.BaseCommit)
+ state.PromptAttributions = nil
+ state.PendingPromptAttribution = nil
+ state.FullyCondensed = true // FilesTouched is already empty (checked above)
+ // Phase stays ENDED — do NOT set to IDLE
+
+ logging.Info(
+ logCtx, "eager condense on session stop succeeded",
+ slog.String("session_id", sessionID),
+ slog.String("checkpoint_id", result.CheckpointID.String()),
+ )
+
+ if err := s.saveSessionState(ctx, state); err != nil {
+ return fmt.Errorf("failed to save session state: %w", err)
+ }
+
+ // Clean up shadow branch
+ if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil {
+ logging.Warn(
+ logCtx, "eager condense: failed to clean up shadow branch",
+ slog.String("shadow_branch", shadowBranchName),
+ slog.String("error", err.Error()),
+ )
+ }
+
+ return nil
+}
+
+// cleanupShadowBranchIfUnused deletes a shadow branch if no other active sessions reference it.
+func (s *ManualCommitStrategy) cleanupShadowBranchIfUnused(ctx context.Context, _ *git.Repository, shadowBranchName, excludeSessionID string) error {
+ // List all session states to check if any other session uses this shadow branch
+ allStates, err := s.listAllSessionStates(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to list session states: %w", err)
+ }
+
+ for _, state := range allStates {
+ if state.SessionID == excludeSessionID {
+ continue
+ }
+ otherShadow := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ if otherShadow == shadowBranchName && state.StepCount > 0 {
+ // Another session still needs this shadow branch
+ return nil
+ }
+ }
+
+ // No other sessions need it, delete the shadow branch via CLI
+ // (go-git v5's RemoveReference doesn't persist with packed refs/worktrees)
+ if err := DeleteBranchCLI(ctx, shadowBranchName); err != nil {
+ // Branch already gone is not an error
+ if errors.Is(err, ErrBranchNotFound) {
+ return nil
+ }
+ return fmt.Errorf("failed to remove shadow branch: %w", err)
+ }
+ return nil
+}
+
+// compactTranscriptForV2 produces the Trace Transcript Format (transcript.jsonl)
+// from a redacted agent transcript. Returns nil if compaction cannot be performed
+// (nil agent, empty transcript, or compaction error) —
+// callers treat nil as "skip writing transcript.jsonl to /main".
+func compactTranscriptForV2(ctx context.Context, ag agent.Agent, transcript redact.RedactedBytes, checkpointTranscriptStart int) []byte {
+ if ag == nil || transcript.Len() == 0 {
+ return nil
+ }
+
+ compacted, err := compact.Compact(transcript, compact.MetadataFields{
+ Agent: string(ag.Name()),
+ CLIVersion: versioninfo.Version,
+ StartLine: checkpointTranscriptStart,
+ })
+ if err != nil {
+ logging.Warn(
+ ctx, "compact transcript generation failed, skipping transcript.jsonl on /main",
+ slog.String("agent", string(ag.Name())),
+ slog.String("error", err.Error()),
+ )
+ return nil
+ }
+ return compacted
+}
+
+// countCompactLines returns line count for compact transcript JSONL.
+func countCompactLines(compactTranscript []byte) int {
+ return bytes.Count(compactTranscript, []byte{'\n'})
+}
diff --git a/cli/strategy/manual_commit_condensation_3.go b/cli/strategy/manual_commit_condensation_3.go
new file mode 100644
index 0000000..fdece59
--- /dev/null
+++ b/cli/strategy/manual_commit_condensation_3.go
@@ -0,0 +1,275 @@
+package strategy
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/transcript/compact"
+ "github.com/GrayCodeAI/trace/cli/versioninfo"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+// computeCompactTranscriptStart chooses the compact transcript start line offset
+// for v2 /main metadata.
+//
+// Preferred source is session state CompactTranscriptStart. For legacy sessions
+// that have only full-transcript offsets persisted, this recalculates the compact
+// offset from transcript bytes when possible. On any failure, returns 0 (fail-open).
+func computeCompactTranscriptStart(ctx context.Context, ag agent.Agent, state *SessionState, transcript []byte, scopedCompact []byte) int {
+ if state.CompactTranscriptStart > 0 {
+ return state.CompactTranscriptStart
+ }
+ if state.CheckpointTranscriptStart == 0 || ag == nil || len(transcript) == 0 || len(scopedCompact) == 0 {
+ return 0
+ }
+
+ // transcript is already redacted (passed as .Bytes() from RedactedBytes).
+ fullCompacted, err := compact.Compact(redact.AlreadyRedacted(transcript), compact.MetadataFields{
+ Agent: string(ag.Name()),
+ CLIVersion: versioninfo.Version,
+ StartLine: 0,
+ })
+ if err != nil || len(fullCompacted) == 0 {
+ logging.Warn(
+ ctx, "failed to recalculate compact transcript start, using 0",
+ slog.String("session_id", state.SessionID),
+ )
+ return 0
+ }
+
+ fullLines := countCompactLines(fullCompacted)
+ scopedLines := countCompactLines(scopedCompact)
+ offset := fullLines - scopedLines
+ if offset < 0 {
+ return 0
+ }
+ return offset
+}
+
+// writeCommittedV2 writes checkpoint data to v2 refs unconditionally.
+// Callers decide whether to propagate or swallow the error (v2-only vs dual-write).
+func writeCommittedV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error {
+ v2URL, err := remote.FetchURL(ctx)
+ if err != nil {
+ logging.Debug(
+ ctx, "manual-commit condensation: using origin for v2 write fetch remote",
+ slog.String("error", err.Error()),
+ )
+ v2URL = originRemote
+ }
+ v2Store := cpkg.NewV2GitStore(repo, v2URL)
+ if err := v2Store.WriteCommitted(ctx, opts); err != nil {
+ return fmt.Errorf("v2 write committed: %w", err)
+ }
+ return nil
+}
+
+// writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2
+// is enabled. Failures are logged as warnings — in dual-write mode v2 writes are
+// best-effort and must not block the v1 path.
+func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) {
+ if !settings.IsCheckpointsV2Enabled(ctx) {
+ return
+ }
+ if err := writeCommittedV2(ctx, repo, opts); err != nil {
+ logging.Warn(
+ ctx, "v2 dual-write failed",
+ slog.String("checkpoint_id", opts.CheckpointID.String()),
+ slog.String("error", err.Error()),
+ )
+ }
+}
+
+// writeTaskMetadataV2IfEnabled copies task metadata trees from the shadow branch
+// to v2 /full/current when dual-write is enabled.
+//
+// This mirrors migrate's task backfill behavior for newly created checkpoints so
+// task rewind artifacts (tasks//...) are available in v2 immediately,
+// not only after running `trace migrate --checkpoints v2`.
+func writeTaskMetadataV2IfEnabled(
+ ctx context.Context,
+ repo *git.Repository,
+ checkpointID id.CheckpointID,
+ sessionID string,
+ shadowRef *plumbing.Reference,
+) {
+ if !settings.IsCheckpointsV2Enabled(ctx) || shadowRef == nil {
+ return
+ }
+
+ shadowCommit, err := repo.CommitObject(shadowRef.Hash())
+ if err != nil {
+ logging.Warn(
+ ctx, "v2 dual-write task metadata copy skipped: failed to read shadow commit",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+
+ shadowTree, err := shadowCommit.Tree()
+ if err != nil {
+ logging.Warn(
+ ctx, "v2 dual-write task metadata copy skipped: failed to read shadow tree",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+
+ tasksPath := paths.SessionMetadataDirFromSessionID(sessionID) + "/tasks"
+ tasksTree, err := shadowTree.Tree(tasksPath)
+ if err != nil {
+ return
+ }
+
+ v2URL, err := remote.FetchURL(ctx)
+ if err != nil {
+ logging.Debug(
+ ctx, "manual-commit condensation: using origin for v2 task metadata fetch remote",
+ slog.String("error", err.Error()),
+ )
+ v2URL = originRemote
+ }
+ v2Store := cpkg.NewV2GitStore(repo, v2URL)
+ sessionIndex, err := resolveV2SessionIndexForCheckpoint(repo, checkpointID, sessionID)
+ if err != nil {
+ logging.Warn(
+ ctx, "v2 dual-write task metadata copy skipped: failed to resolve session index",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+
+ if err := spliceTaskTreeToV2FullCurrent(ctx, repo, v2Store, checkpointID, sessionIndex, tasksTree.Hash); err != nil {
+ logging.Warn(
+ ctx, "v2 dual-write task metadata copy failed",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("session_id", sessionID),
+ slog.String("error", err.Error()),
+ )
+ }
+}
+
+func resolveV2SessionIndexForCheckpoint(repo *git.Repository, checkpointID id.CheckpointID, sessionID string) (int, error) {
+ v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ if err != nil {
+ return 0, fmt.Errorf("read v2 /main ref: %w", err)
+ }
+ v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
+ if err != nil {
+ return 0, fmt.Errorf("read v2 /main commit: %w", err)
+ }
+ v2MainTree, err := v2MainCommit.Tree()
+ if err != nil {
+ return 0, fmt.Errorf("read v2 /main tree: %w", err)
+ }
+
+ checkpointTree, err := v2MainTree.Tree(checkpointID.Path())
+ if err != nil {
+ return 0, fmt.Errorf("read checkpoint subtree on v2 /main: %w", err)
+ }
+
+ metadataFile, err := checkpointTree.File(paths.MetadataFileName)
+ if err != nil {
+ return 0, fmt.Errorf("read checkpoint summary metadata: %w", err)
+ }
+ metadataContent, err := metadataFile.Contents()
+ if err != nil {
+ return 0, fmt.Errorf("read checkpoint summary contents: %w", err)
+ }
+
+ var summary cpkg.CheckpointSummary
+ if err := json.Unmarshal([]byte(metadataContent), &summary); err != nil {
+ return 0, fmt.Errorf("parse checkpoint summary metadata: %w", err)
+ }
+
+ for i := range len(summary.Sessions) {
+ sessionTree, err := checkpointTree.Tree(strconv.Itoa(i))
+ if err != nil {
+ continue
+ }
+ sessionMetadataFile, err := sessionTree.File(paths.MetadataFileName)
+ if err != nil {
+ continue
+ }
+ sessionMetadataContent, err := sessionMetadataFile.Contents()
+ if err != nil {
+ continue
+ }
+
+ var sessionMeta cpkg.CommittedMetadata
+ if err := json.Unmarshal([]byte(sessionMetadataContent), &sessionMeta); err != nil {
+ continue
+ }
+ if sessionMeta.SessionID == sessionID {
+ return i, nil
+ }
+ }
+
+ return 0, fmt.Errorf("session %q not found in v2 checkpoint %s", sessionID, checkpointID)
+}
+
+func spliceTaskTreeToV2FullCurrent(
+ ctx context.Context,
+ repo *git.Repository,
+ v2Store *cpkg.V2GitStore,
+ checkpointID id.CheckpointID,
+ sessionIndex int,
+ tasksTreeHash plumbing.Hash,
+) error {
+ refName := plumbing.ReferenceName(paths.V2FullCurrentRefName)
+ parentHash, rootTreeHash, err := v2Store.GetRefState(refName)
+ if err != nil {
+ return fmt.Errorf("get v2 /full/current ref state: %w", err)
+ }
+ incomingTasksTree, err := repo.TreeObject(tasksTreeHash)
+ if err != nil {
+ return fmt.Errorf("read task tree: %w", err)
+ }
+
+ shardPrefix := string(checkpointID[:2])
+ shardSuffix := string(checkpointID[2:])
+ sessionDir := strconv.Itoa(sessionIndex)
+
+ newRootHash, err := cpkg.UpdateSubtree(
+ repo, rootTreeHash,
+ []string{shardPrefix, shardSuffix, sessionDir, "tasks"},
+ incomingTasksTree.Entries,
+ cpkg.UpdateSubtreeOptions{MergeMode: cpkg.MergeKeepExisting},
+ )
+ if err != nil {
+ return fmt.Errorf("splice task tree into v2 /full/current: %w", err)
+ }
+
+ authorName, authorEmail := cpkg.GetGitAuthorFromRepo(repo)
+ commitHash, err := cpkg.CreateCommit(ctx, repo, newRootHash, parentHash,
+ fmt.Sprintf("Checkpoint: %s (task metadata)\n", checkpointID),
+ authorName, authorEmail)
+ if err != nil {
+ return fmt.Errorf("create v2 task metadata commit: %w", err)
+ }
+
+ if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)); err != nil {
+ return fmt.Errorf("update v2 /full/current ref: %w", err)
+ }
+
+ return nil
+}
diff --git a/cli/strategy/manual_commit_hooks.go b/cli/strategy/manual_commit_hooks.go
index 38b26db..a1947ec 100644
--- a/cli/strategy/manual_commit_hooks.go
+++ b/cli/strategy/manual_commit_hooks.go
@@ -2,25 +2,19 @@ package strategy
import (
"bufio"
- "bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
- "os/exec"
"path/filepath"
"strings"
"time"
- "github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/types"
- "github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
- "github.com/GrayCodeAI/trace/cli/gitops"
"github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/paths"
@@ -29,12 +23,10 @@ import (
"github.com/GrayCodeAI/trace/cli/stringutil"
"github.com/GrayCodeAI/trace/cli/trailers"
"github.com/GrayCodeAI/trace/perf"
- "github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
- "github.com/go-git/go-git/v6/utils/binary"
)
// ttyResult represents the outcome of a TTY confirmation prompt.
@@ -836,2208 +828,3 @@ var stderrWriter io.Writer = os.Stderr
func warnStaleEndedSessions(ctx context.Context, count int) {
warnStaleEndedSessionsTo(ctx, count, stderrWriter)
}
-
-func warnStaleEndedSessionsTo(ctx context.Context, count int, w io.Writer) {
- commonDir, err := GetGitCommonDir(ctx)
- if err != nil {
- return // fail-open
- }
- warnDir := filepath.Join(commonDir, session.SessionStateDirName)
- warnFile := filepath.Join(warnDir, staleEndedSessionWarnFile)
- if info, statErr := os.Stat(warnFile); statErr == nil {
- if time.Since(info.ModTime()) < staleEndedSessionWarnInterval {
- return // rate-limited
- }
- }
- //nolint:errcheck,gosec // G104: Best-effort warning — fail-open if file ops fail
- os.MkdirAll(warnDir, 0o750)
- //nolint:errcheck,gosec // G104: Best-effort sentinel file write
- os.WriteFile(warnFile, []byte{}, 0o644)
- fmt.Fprintf(
- w,
- "\ntrace: %d ended session(s) are accumulating and slowing down commits.\n"+
- "Run 'trace doctor' to condense them and restore commit performance.\n\n",
- count,
- )
-}
-
-// activeSessionInteractionThreshold is the maximum age of LastInteractionTime
-// for an ACTIVE session to be considered genuinely active. 24h is generous
-// because LastInteractionTime only updates at TurnStart, not per-tool-call.
-const activeSessionInteractionThreshold = 24 * time.Hour
-
-// isRecentInteraction returns true if lastInteraction is non-nil and within
-// activeSessionInteractionThreshold of now.
-func isRecentInteraction(lastInteraction *time.Time) bool {
- return lastInteraction != nil && time.Since(*lastInteraction) < activeSessionInteractionThreshold
-}
-
-func (h *postCommitActionHandler) HandleDiscardIfNoFiles(state *session.State) error {
- if len(state.FilesTouched) == 0 {
- logging.Debug(
- logging.WithComponent(h.ctx, "checkpoint"), "post-commit: skipping empty ended session (no files to condense)",
- slog.String("session_id", state.SessionID),
- )
- }
- h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead)
- return nil
-}
-
-func (h *postCommitActionHandler) HandleWarnStaleSession(_ *session.State) error {
- // Not produced by EventGitCommit; no-op for exhaustiveness.
- return nil
-}
-
-// During rebase/cherry-pick/revert operations, phase transitions are skipped entirely.
-//
-
-func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:unparam // error return is part of the hook contract; callers check it
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- _, openRepoSpan := perf.Start(ctx, "open_repository_and_head")
- repo, err := OpenRepository(ctx)
- if err != nil {
- openRepoSpan.RecordError(err)
- openRepoSpan.End()
- return nil
- }
-
- // Get HEAD commit to check for trailer
- head, err := repo.Head()
- if err != nil {
- openRepoSpan.RecordError(err)
- openRepoSpan.End()
- return nil
- }
-
- commit, err := repo.CommitObject(head.Hash())
- if err != nil {
- openRepoSpan.RecordError(err)
- openRepoSpan.End()
- return nil
- }
-
- // Check if commit has checkpoint trailer (ParseCheckpoint validates format)
- checkpointID, found := trailers.ParseCheckpoint(commit.Message)
- openRepoSpan.End()
-
- if !found {
- // No trailer — user removed it or it was never added (mid-turn commit).
- // Still update BaseCommit for active sessions so future commits can match.
- s.postCommitUpdateBaseCommitOnly(ctx, head)
- return nil
- }
-
- _, findSessionsSpan := perf.Start(ctx, "find_sessions_for_worktree")
- worktreePath, err := paths.WorktreeRoot(ctx)
- if err != nil {
- findSessionsSpan.RecordError(err)
- findSessionsSpan.End()
- return nil
- }
-
- // Find all active sessions for this worktree
- sessions, err := s.findSessionsForWorktree(ctx, worktreePath)
- findSessionsSpan.RecordError(err)
- findSessionsSpan.End()
-
- if err != nil || len(sessions) == 0 {
- logging.Warn(
- logCtx, "post-commit: no active sessions despite trailer",
- slog.String("strategy", "manual-commit"),
- slog.String("checkpoint_id", checkpointID.String()),
- )
- return nil //nolint:nilerr // Intentional: hooks must be silent on failure
- }
-
- // Build transition context
- isRebase := isGitSequenceOperation(ctx)
- transitionCtx := session.TransitionContext{
- IsRebaseInProgress: isRebase,
- }
-
- if isRebase {
- logging.Debug(
- logCtx, "post-commit: rebase/sequence in progress, skipping phase transitions",
- slog.String("strategy", "manual-commit"),
- )
- }
-
- // Track shadow branch names and whether they can be deleted
- shadowBranchesToDelete := make(map[string]struct{})
- // Track active sessions that were NOT condensed — their shadow branches must be preserved
- uncondensedActiveOnBranch := make(map[string]bool)
-
- newHead := head.Hash().String()
-
- // Pre-resolve HEAD tree and parent tree once for the trace PostCommit.
- // These are immutable within this hook invocation and used by multiple
- // per-session functions (filesOverlapWithContent, filesWithRemainingAgentChanges,
- // calculateSessionAttributions).
- _, resolveTreesSpan := perf.Start(ctx, "resolve_commit_trees")
- var headTree *object.Tree
- if t, err := commit.Tree(); err == nil {
- headTree = t
- }
- var parentTree *object.Tree
- if commit.NumParents() > 0 {
- if parent, err := commit.Parent(0); err == nil {
- if t, err := parent.Tree(); err == nil {
- parentTree = t
- }
- }
- }
-
- committedFileSet := filesChangedInCommit(ctx, worktreePath, commit, headTree, parentTree)
- resolveTreesSpan.End()
-
- // Compute union of all sessions' FilesTouched for cross-session attribution,
- // and count sessions whose tracked files overlap with committed files.
- allAgentFiles := make(map[string]struct{})
- sessionsWithCommittedFiles := 0
- for _, state := range sessions {
- if state.FullyCondensed && state.Phase == session.PhaseEnded {
- continue
- }
- for _, f := range state.FilesTouched {
- allAgentFiles[f] = struct{}{}
- if _, ok := committedFileSet[f]; ok {
- sessionsWithCommittedFiles++
- break // count each session at most once
- }
- }
- }
-
- loopCtx, processSessionsLoop := perf.StartLoop(ctx, "process_sessions")
- for _, state := range sessions {
- // Skip fully-condensed ended sessions — no work remains.
- // These sessions only persist for LastCheckpointID (amend trailer reuse).
- if state.FullyCondensed && state.Phase == session.PhaseEnded {
- continue
- }
- iterCtx, iterSpan := processSessionsLoop.Iteration(loopCtx)
- s.postCommitProcessSession(iterCtx, repo, state, &transitionCtx, checkpointID,
- head, commit, newHead, worktreePath, headTree, parentTree,
- committedFileSet, shadowBranchesToDelete, uncondensedActiveOnBranch, allAgentFiles,
- sessionsWithCommittedFiles)
- iterSpan.End()
- }
- processSessionsLoop.End()
-
- if err := s.updateCombinedAttributionForCheckpoint(ctx, repo, checkpointID, headTree, parentTree, worktreePath); err != nil {
- logging.Warn(logCtx, "failed to update combined checkpoint attribution",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.String("error", err.Error()))
- }
-
- // Clean up shadow branches — only delete when ALL sessions on the branch are non-active
- // or were condensed during this PostCommit.
- _, cleanupBranchesSpan := perf.Start(ctx, "cleanup_shadow_branches")
- for shadowBranchName := range shadowBranchesToDelete {
- if uncondensedActiveOnBranch[shadowBranchName] {
- logging.Debug(
- logCtx, "post-commit: preserving shadow branch (active session exists)",
- slog.String("shadow_branch", shadowBranchName),
- )
- continue
- }
- if err := deleteShadowBranch(ctx, repo, shadowBranchName); err != nil {
- logging.Warn(logCtx, "failed to clean up shadow branch",
- slog.String("shadow_branch", shadowBranchName),
- slog.String("error", err.Error()))
- } else {
- logging.Info(
- logCtx, "shadow branch deleted",
- slog.String("strategy", "manual-commit"),
- slog.String("shadow_branch", shadowBranchName),
- )
- }
- }
- cleanupBranchesSpan.End()
-
- if stale := countWarnableStaleEndedSessions(repo, sessions); stale >= staleEndedSessionWarnThreshold {
- warnStaleEndedSessions(ctx, stale)
- }
-
- return nil
-}
-
-// updateCombinedAttributionForCheckpoint computes holistic attribution across all sessions.
-// Instead of summing per-session numbers (which inflates totals because each session
-// independently counts the full commit), this diffs parent→HEAD once and classifies
-// lines as agent or human based on the union of all sessions' files_touched.
-func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(
- ctx context.Context,
- repo *git.Repository,
- checkpointID id.CheckpointID,
- headTree, parentTree *object.Tree,
- repoDir string,
-) error {
- logCtx := logging.WithComponent(ctx, "attribution")
- store := checkpoint.NewGitStore(repo)
-
- summary, err := store.ReadCommitted(ctx, checkpointID)
- if err != nil {
- return fmt.Errorf("reading checkpoint summary: %w", err)
- }
- if summary == nil || len(summary.Sessions) <= 1 {
- return nil
- }
-
- // Collect union of files_touched from sessions that had real checkpoints (SaveStep ran).
- // Sessions with checkpoints_count == 0 (e.g., commit-only sessions) use a fallback that
- // includes ALL committed files, which would incorrectly classify human-created files as agent work.
- agentFiles := make(map[string]struct{})
- for i := range len(summary.Sessions) {
- metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i)
- if readErr != nil || metadata == nil {
- continue
- }
- if metadata.CheckpointsCount == 0 {
- continue // Skip sessions that used the filesTouched fallback
- }
- for _, f := range metadata.FilesTouched {
- agentFiles[f] = struct{}{}
- }
- }
-
- if len(agentFiles) == 0 {
- return nil
- }
-
- // Get all files changed in this commit (parent → HEAD)
- allChangedFiles, err := getAllChangedFiles(ctx, parentTree, headTree, repoDir, "", "")
- if err != nil {
- logging.Warn(logCtx, "combined attribution: failed to enumerate changed files",
- slog.String("error", err.Error()))
- return nil
- }
-
- // Classify each changed file as agent or human and count lines
- var agentAdded, agentRemoved, humanAdded, humanRemoved int
- for _, filePath := range allChangedFiles {
- // Skip CLI/agent config metadata — not human or agent code work
- if strings.HasPrefix(filePath, ".trace/") || strings.HasPrefix(filePath, paths.TraceMetadataDir+"/") ||
- strings.HasPrefix(filePath, ".claude/") {
- continue
- }
-
- parentContent := getFileContent(parentTree, filePath)
- headContent := getFileContent(headTree, filePath)
- _, added, removed := diffLines(parentContent, headContent)
-
- if _, isAgent := agentFiles[filePath]; isAgent {
- agentAdded += added
- agentRemoved += removed
- } else {
- humanAdded += added
- humanRemoved += removed
- }
- }
-
- totalLinesChanged := agentAdded + agentRemoved + humanAdded + humanRemoved
- totalCommitted := agentAdded + humanAdded
-
- var agentPercentage float64
- if totalLinesChanged > 0 {
- agentPercentage = float64(agentAdded+agentRemoved) / float64(totalLinesChanged) * 100
- }
-
- combined := &checkpoint.InitialAttribution{
- CalculatedAt: time.Now().UTC(),
- AgentLines: agentAdded,
- AgentRemoved: agentRemoved,
- HumanAdded: humanAdded,
- HumanRemoved: humanRemoved,
- TotalCommitted: totalCommitted,
- TotalLinesChanged: totalLinesChanged,
- AgentPercentage: agentPercentage,
- MetricVersion: 2,
- }
-
- logging.Info(
- logCtx, "combined attribution calculated",
- slog.String("checkpoint_id", checkpointID.String()),
- slog.Int("sessions", len(summary.Sessions)),
- slog.Int("agent_files", len(agentFiles)),
- slog.Int("agent_lines", agentAdded),
- slog.Int("human_added", humanAdded),
- slog.Float64("agent_percentage", agentPercentage),
- )
-
- if err := store.UpdateCheckpointSummary(ctx, checkpointID, combined); err != nil {
- return fmt.Errorf("persisting combined attribution: %w", err)
- }
-
- return nil
-}
-
-// postCommitProcessSession handles a single session within the PostCommit loop.
-// Pre-resolved git objects (headTree, parentTree) are shared across all sessions;
-// per-session shadow ref/tree are resolved once here and threaded through sub-calls.
-func (s *ManualCommitStrategy) postCommitProcessSession(
- ctx context.Context,
- repo *git.Repository,
- state *SessionState,
- transitionCtx *session.TransitionContext,
- checkpointID id.CheckpointID,
- head *plumbing.Reference,
- commit *object.Commit,
- newHead string,
- repoDir string,
- headTree, parentTree *object.Tree,
- committedFileSet map[string]struct{},
- shadowBranchesToDelete map[string]struct{},
- uncondensedActiveOnBranch map[string]bool,
- allAgentFiles map[string]struct{},
- sessionsWithCommittedFiles int,
-) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
-
- // Pre-resolve shadow branch ref and tree for this session.
- // These are read 4+ times across sessionHasNewContent, filesOverlapWithContent,
- // CondenseSession, filesWithRemainingAgentChanges, and calculateSessionAttributions.
- _, resolveShadowBranchSpan := perf.Start(ctx, "resolve_shadow_branch")
- var shadowRef *plumbing.Reference
- var shadowTree *object.Tree
- if ref, refErr := repo.Reference(plumbing.NewBranchReferenceName(shadowBranchName), true); refErr == nil {
- shadowRef = ref
- if sc, scErr := repo.CommitObject(ref.Hash()); scErr == nil {
- if st, stErr := sc.Tree(); stErr == nil {
- shadowTree = st
- }
- }
- }
- resolveShadowBranchSpan.End()
-
- // Check for new content (needed for TransitionContext and condensation).
- // Fail-open: if content check errors, assume new content exists so we
- // don't silently skip data that should have been condensed.
- //
- // For ACTIVE sessions: the commit has a checkpoint trailer (verified above),
- // meaning PrepareCommitMsg already determined this commit is session-related.
- // The trailer is only added when either:
- // - No TTY (agent/subagent committing) — added unconditionally
- // - TTY (human committing) — added after content detection confirmed agent work
- // In both cases, PrepareCommitMsg already validated this commit. We trust
- // that decision here. Transcript-based re-validation is unreliable because
- // subagent transcripts may not be available yet (subagent still running).
- _, checkContentSpan := perf.Start(ctx, "check_session_content")
- var hasNew bool
- if state.Phase.IsActive() {
- hasNew = true
- } else {
- var contentErr error
- hasNew, contentErr = s.sessionHasNewContent(ctx, repo, state, contentCheckOpts{shadowTree: shadowTree})
- if contentErr != nil {
- hasNew = true
- logging.Debug(
- logCtx, "post-commit: error checking session content, assuming new content",
- slog.String("session_id", state.SessionID),
- slog.String("error", contentErr.Error()),
- )
- }
- }
- transitionCtx.HasFilesTouched = len(state.FilesTouched) > 0
-
- // Save FilesTouched BEFORE TransitionAndLog — the handler's condensation
- // clears it, but we need the original list for carry-forward computation.
- // Only fall back to transcript extraction for ACTIVE sessions — IDLE/ENDED
- // sessions have FilesTouched already populated by SaveStep/mergeFilesTouched.
- var filesTouchedBefore []string
- if state.Phase.IsActive() {
- filesTouchedBefore = s.resolveFilesTouched(ctx, state)
- } else if len(state.FilesTouched) > 0 {
- filesTouchedBefore = make([]string, len(state.FilesTouched))
- copy(filesTouchedBefore, state.FilesTouched)
- }
- checkContentSpan.End()
-
- logging.Debug(
- logCtx, "post-commit: carry-forward prep",
- slog.String("session_id", state.SessionID),
- slog.Bool("is_active", state.Phase.IsActive()),
- slog.String("transcript_path", state.TranscriptPath),
- slog.Int("files_touched_before", len(filesTouchedBefore)),
- slog.Any("files", filesTouchedBefore),
- )
-
- // Run the state machine transition with handler for strategy-specific actions.
- _, transitionAndCondenseSpan := perf.Start(ctx, "transition_and_condense")
- handler := &postCommitActionHandler{
- s: s,
- ctx: ctx,
- repo: repo,
- checkpointID: checkpointID,
- head: head,
- commit: commit,
- newHead: newHead,
- repoDir: repoDir,
- shadowBranchName: shadowBranchName,
- shadowBranchesToDelete: shadowBranchesToDelete,
- committedFileSet: committedFileSet,
- hasNew: hasNew,
- filesTouchedBefore: filesTouchedBefore,
- headTree: headTree,
- parentTree: parentTree,
- shadowRef: shadowRef,
- shadowTree: shadowTree,
- allAgentFiles: allAgentFiles,
- sessionsWithCommittedFiles: sessionsWithCommittedFiles,
- }
-
- if err := TransitionAndLog(ctx, state, session.EventGitCommit, *transitionCtx, handler); err != nil {
- logging.Warn(logCtx, "post-commit action handler error",
- slog.String("error", err.Error()))
- }
- transitionAndCondenseSpan.End()
-
- // Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize
- // with full transcript. IDLE/ENDED sessions already have complete transcripts.
- // NOTE: This check runs AFTER TransitionAndLog updated the phase. It relies on
- // ACTIVE + GitCommit → ACTIVE (phase stays ACTIVE). If that state machine
- // transition ever changed, this guard would silently stop recording IDs.
- if handler.condensed && state.Phase.IsActive() {
- state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String())
- }
-
- // Carry forward remaining uncommitted files so the next commit gets its
- // own checkpoint ID. This applies to ALL phases — if a user splits their
- // commit across two `git commit` invocations, each gets a 1:1 checkpoint.
- // Uses content-aware comparison: if user did `git add -p` and committed
- // partial changes, the file still has remaining agent changes to carry forward.
- _, carryForwardSpan := perf.Start(ctx, "carry_forward_files")
- if handler.condensed {
- remainingFiles := filesWithRemainingAgentChanges(ctx, repo, shadowBranchName, commit, filesTouchedBefore, committedFileSet, overlapOpts{
- headTree: headTree,
- shadowTree: shadowTree,
- })
- state.FilesTouched = remainingFiles
- logging.Debug(
- logCtx, "post-commit: carry-forward decision (content-aware)",
- slog.String("session_id", state.SessionID),
- slog.Int("files_touched_before", len(filesTouchedBefore)),
- slog.Int("committed_files", len(committedFileSet)),
- slog.Int("remaining_files", len(remainingFiles)),
- slog.Any("remaining", remainingFiles),
- slog.Any("committed_files", committedFileSet),
- )
- if len(remainingFiles) > 0 {
- s.carryForwardToNewShadowBranch(ctx, repo, state, remainingFiles)
- }
-
- // Clear filesystem prompt.txt only when ALL files are committed.
- // If carry-forward files remain, the prompt must persist so the next
- // condensation (triggered by the next commit) can read it.
- if len(state.FilesTouched) == 0 {
- clearFilesystemPrompt(ctx, state.SessionID)
- }
- }
- carryForwardSpan.End()
-
- // Mark ENDED sessions as fully condensed when there's nothing left to do.
- // Either we just condensed (no carry-forward remains) or there was never any
- // new content. PostCommit will skip these on future commits; they persist only
- // for LastCheckpointID (amend trailer restoration).
- if state.Phase == session.PhaseEnded && len(state.FilesTouched) == 0 && (handler.condensed || !hasNew) {
- state.FullyCondensed = true
- }
-
- // Save the updated state
- _, saveSessionStateSpan := perf.Start(ctx, "save_session_state")
- if err := s.saveSessionState(ctx, state); err != nil {
- logging.Warn(logCtx, "failed to update session state",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()))
- }
- saveSessionStateSpan.End()
-
- // Only preserve shadow branch for active sessions that were NOT condensed.
- // Condensed sessions already have their data on trace/checkpoints/v1.
- if state.Phase.IsActive() && !handler.condensed {
- uncondensedActiveOnBranch[shadowBranchName] = true
- }
-}
-
-// condenseAndUpdateState runs condensation for a session and updates state afterward.
-// Returns true if condensation succeeded.
-func (s *ManualCommitStrategy) condenseAndUpdateState(
- ctx context.Context,
- repo *git.Repository,
- checkpointID id.CheckpointID,
- state *SessionState,
- head *plumbing.Reference,
- shadowBranchName string,
- shadowBranchesToDelete map[string]struct{},
- committedFiles map[string]struct{},
- opts ...condenseOpts,
-) bool {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- result, err := s.CondenseSession(ctx, repo, checkpointID, state, committedFiles, opts...)
- if err != nil {
- logging.Warn(
- logCtx, "condensation failed",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()),
- )
- return false
- }
-
- if result.Skipped {
- logging.Debug(
- logCtx, "condensation skipped, session state unchanged",
- slog.String("session_id", state.SessionID),
- slog.String("checkpoint_id", checkpointID.String()),
- )
- return false
- }
-
- // Track this shadow branch for cleanup
- shadowBranchesToDelete[shadowBranchName] = struct{}{}
-
- // Update session state for the new base commit
- newHead := head.Hash().String()
- state.BaseCommit = newHead
- state.RealignAttributionBase(newHead)
- state.StepCount = 0
- state.CheckpointTranscriptStart = result.TotalTranscriptLines
- state.CompactTranscriptStart += result.CompactTranscriptLines
- state.CheckpointTranscriptSize = int64(len(result.Transcript))
-
- // Clear attribution tracking — condensation already used these values
- state.PromptAttributions = nil
- state.PendingPromptAttribution = nil
- state.FilesTouched = nil
-
- // NOTE: filesystem prompt.txt is NOT cleared here. The caller (PostCommit handler)
- // decides whether to clear it based on carry-forward: if remaining files exist,
- // the prompt must persist so the next condensation can read it.
-
- // Save checkpoint ID so subsequent commits can reuse it (e.g., amend restores trailer).
- // LastCheckpointCommitHash records the exact commit SHA so the reconcile path can
- // distinguish a true reset (same SHA) from cherry-pick/rebase (same trailer, new SHA).
- state.LastCheckpointID = checkpointID
- state.LastCheckpointCommitHash = newHead
-
- logging.Info(
- logCtx, "session condensed",
- slog.String("strategy", "manual-commit"),
- slog.String("session_id", state.SessionID),
- slog.String("checkpoint_id", result.CheckpointID.String()),
- slog.Int("checkpoints_condensed", result.CheckpointsCount),
- slog.Int("transcript_lines", result.TotalTranscriptLines),
- )
-
- return true
-}
-
-// updateBaseCommitIfChanged updates BaseCommit to newHead if it changed.
-// Only updates ACTIVE sessions. IDLE/ENDED sessions should NOT have their
-// BaseCommit updated, as this would cause them to be incorrectly associated
-// with a new shadow branch and potentially condensed on future commits.
-func (s *ManualCommitStrategy) updateBaseCommitIfChanged(ctx context.Context, state *SessionState, newHead string) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- // Only update ACTIVE sessions. IDLE/ENDED sessions are kept around for
- // LastCheckpointID reuse and should not be advanced to HEAD.
- if !state.Phase.IsActive() {
- logging.Debug(
- logCtx, "post-commit: updateBaseCommitIfChanged skipped non-active session",
- slog.String("session_id", state.SessionID),
- slog.String("phase", string(state.Phase)),
- )
- return
- }
- if state.BaseCommit != newHead {
- state.BaseCommit = newHead
- // Keep AttributionBaseCommit in sync to prevent stale base drift.
- // Without this, a subsequent condensation would diff from the old base,
- // inflating human_added with lines from unrelated prior commits.
- state.RealignAttributionBase(newHead)
- logging.Debug(
- logCtx, "post-commit: updated BaseCommit and AttributionBaseCommit",
- slog.String("session_id", state.SessionID),
- slog.String("new_head", truncateHash(newHead)),
- )
- }
-}
-
-// postCommitUpdateBaseCommitOnly updates BaseCommit for all sessions on the current
-// worktree when a commit has no Trace-Checkpoint trailer. This prevents BaseCommit
-// from going stale, which would cause future PrepareCommitMsg calls to skip the
-// session (BaseCommit != currentHeadHash filter).
-//
-// Unlike the full PostCommit flow, this does NOT fire EventGitCommit or trigger
-// condensation — it only keeps BaseCommit in sync with HEAD.
-func (s *ManualCommitStrategy) postCommitUpdateBaseCommitOnly(ctx context.Context, head *plumbing.Reference) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- worktreePath, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return // Silent failure — hooks must be resilient
- }
-
- sessions, err := s.findSessionsForWorktree(ctx, worktreePath)
- if err != nil || len(sessions) == 0 {
- return
- }
-
- newHead := head.Hash().String()
- for _, state := range sessions {
- // Only update active sessions. Idle/ended sessions are kept around for
- // LastCheckpointID reuse and should not be advanced to HEAD.
- if !state.Phase.IsActive() {
- continue
- }
- if state.BaseCommit != newHead {
- logging.Debug(
- logCtx, "post-commit (no trailer): updating BaseCommit and AttributionBaseCommit",
- slog.String("session_id", state.SessionID),
- slog.String("old_base", truncateHash(state.BaseCommit)),
- slog.String("new_head", truncateHash(newHead)),
- )
- state.BaseCommit = newHead
- // Keep AttributionBaseCommit in sync to prevent stale base drift.
- // Without this, a subsequent condensation would diff from the old base,
- // inflating human_added with lines from unrelated prior commits.
- state.RealignAttributionBase(newHead)
- if err := s.saveSessionState(ctx, state); err != nil {
- logging.Warn(logCtx, "failed to update session state",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()))
- }
- }
- }
-}
-
-// truncateHash safely truncates a git hash to 7 chars for logging.
-func truncateHash(h string) string {
- if len(h) > 7 {
- return h[:7]
- }
- return h
-}
-
-// filterSessionsWithNewContent returns sessions that have new transcript content
-// beyond what was already condensed.
-// Computes the staged files list once and reuses it across all sessions to avoid
-// redundant `git diff --cached` calls (previously called up to 3 times per session).
-func (s *ManualCommitStrategy) filterSessionsWithNewContent(ctx context.Context, repo *git.Repository, sessions []*SessionState) []*SessionState {
- logCtx := logging.WithComponent(ctx, "manual-commit")
- var result []*SessionState
-
- // Compute staged files once for all sessions.
- // On error, pass nil — sessionHasNewContent treats nil stagedFiles as
- // "unavailable" and skips overlap checks, falling through to other heuristics.
- stagedFiles, err := getStagedFiles(ctx)
- if err != nil {
- logging.Debug(
- logCtx,
- "filterSessionsWithNewContent: getStagedFiles failed, skipping overlap checks",
- slog.String("error", err.Error()),
- )
- stagedFiles = nil
- }
-
- for _, state := range sessions {
- // Skip fully-condensed ended sessions — no new content possible.
- if state.FullyCondensed && state.Phase == session.PhaseEnded {
- logging.Debug(
- logCtx, "filterSessionsWithNewContent: skipping fully-condensed ended session",
- slog.String("session_id", state.SessionID),
- )
- continue
- }
- hasNew, err := s.sessionHasNewContent(ctx, repo, state, contentCheckOpts{stagedFiles: stagedFiles})
- if err != nil {
- logging.Debug(
- logCtx, "filterSessionsWithNewContent: error checking session, skipping it",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()),
- )
- continue
- }
- if !hasNew {
- logging.Debug(
- logCtx, "filterSessionsWithNewContent: session has no new content",
- slog.String("session_id", state.SessionID),
- slog.String("phase", string(state.Phase)),
- slog.Int("files_touched", len(state.FilesTouched)),
- )
- }
- if hasNew {
- result = append(result, state)
- }
- }
-
- return result
-}
-
-// contentCheckOpts holds pre-computed values for sessionHasNewContent to avoid
-// redundant work across multiple sessions in a single hook invocation.
-type contentCheckOpts struct {
- // stagedFiles is the pre-computed list of staged files (from getStagedFiles).
- // nil means staged files are unavailable (error or PostCommit context where
- // files are already committed) — callers skip overlap checks and fall through
- // to other heuristics (e.g., transcript growth).
- // Non-nil empty means successfully resolved but no files are staged.
- stagedFiles []string
-
- // shadowTree, when non-nil, is used directly to avoid redundant shadow branch
- // resolution (the shadow ref/commit/tree were already resolved by the caller).
- shadowTree *object.Tree
-}
-
-// sessionHasNewContent checks if a session has new transcript content
-// beyond what was already condensed.
-// The opts parameter provides pre-computed values to avoid redundant work.
-func (s *ManualCommitStrategy) sessionHasNewContent(ctx context.Context, repo *git.Repository, state *SessionState, opts contentCheckOpts) (bool, error) {
- logCtx := logging.WithComponent(ctx, "manual-commit")
-
- // Use cached shadow tree if provided
- var tree *object.Tree
- if opts.shadowTree != nil {
- tree = opts.shadowTree
- } else {
- // Resolve shadow branch from repo
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- logging.Debug(
- logCtx, "sessionHasNewContent: no shadow branch, checking live transcript",
- slog.String("session_id", state.SessionID),
- slog.String("shadow_branch", shadowBranchName),
- )
- return s.sessionHasNewContentFromLiveTranscript(ctx, state, opts.stagedFiles)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return false, fmt.Errorf("failed to get commit object: %w", err)
- }
-
- tree, err = commit.Tree()
- if err != nil {
- return false, fmt.Errorf("failed to get commit tree: %w", err)
- }
- }
-
- // Look for transcript file — use blob size for fast growth check when possible.
- // This avoids reading the full transcript content (potentially tens of MB) just
- // to count lines, which was the main source of PostCommit latency with many sessions.
- metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
- var hasTranscriptFile bool
- var transcriptBlobSize int64
-
- if size, sizeErr := tree.Size(metadataDir + "/" + paths.TranscriptFileName); sizeErr == nil {
- hasTranscriptFile = true
- transcriptBlobSize = size
- } else if size, sizeErr := tree.Size(metadataDir + "/" + paths.TranscriptFileNameLegacy); sizeErr == nil {
- hasTranscriptFile = true
- transcriptBlobSize = size
- }
-
- // If shadow branch exists but has no transcript (e.g., carry-forward from mid-session commit),
- // check if the session has FilesTouched. Carry-forward sets FilesTouched with remaining files.
- if !hasTranscriptFile {
- if len(state.FilesTouched) > 0 {
- // Shadow branch has files from carry-forward - check if staged files overlap
- // AND have matching content (content-aware check).
- if len(opts.stagedFiles) > 0 {
- // PrepareCommitMsg context: check staged files overlap with content
- result := stagedFilesOverlapWithContent(ctx, repo, tree, opts.stagedFiles, state.FilesTouched)
- logging.Debug(
- logCtx, "sessionHasNewContent: no transcript, carry-forward with staged files",
- slog.String("session_id", state.SessionID),
- slog.Int("files_touched", len(state.FilesTouched)),
- slog.Int("staged_files", len(opts.stagedFiles)),
- slog.Bool("result", result),
- )
- return result, nil
- }
- // PostCommit context: no staged files, but we have carry-forward files.
- // Return true and let the caller do the overlap check with committed files.
- logging.Debug(
- logCtx, "sessionHasNewContent: no transcript, carry-forward without staged files (post-commit context)",
- slog.String("session_id", state.SessionID),
- slog.Int("files_touched", len(state.FilesTouched)),
- )
- return true, nil
- }
- // No transcript and no FilesTouched - fall back to live transcript check
- logging.Debug(
- logCtx, "sessionHasNewContent: no transcript and no files touched, checking live transcript",
- slog.String("session_id", state.SessionID),
- )
- return s.sessionHasNewContentFromLiveTranscript(ctx, state, opts.stagedFiles)
- }
-
- // Check if there's new content to condense. Two cases:
- // 1. Transcript has grown since last condensation (new prompts/responses)
- // 2. FilesTouched has files not yet committed (carry-forward scenario)
- //
- // For PrepareCommitMsg context, we verify staged files overlap with session's files
- // using content-aware matching to detect reverted files.
- // For PostCommit context, stagedFiles is nil/empty (files already committed),
- // so we return true and let the caller do the overlap check via filesOverlapWithContent.
-
- // Fast path: compare blob size against stored size from last condensation.
- // This avoids reading the full transcript content just to count items.
- var hasTranscriptGrowth bool
- switch {
- case state.CheckpointTranscriptSize > 0:
- hasTranscriptGrowth = transcriptBlobSize > state.CheckpointTranscriptSize
- case state.CheckpointTranscriptStart > 0:
- // Legacy session: condensed at least once (has line count) but no size tracking.
- // Cannot safely compare sizes — conservatively assume growth so condensation
- // can do the full content check. After one condensation with the new CLI,
- // CheckpointTranscriptSize will be populated and this path won't be hit again.
- hasTranscriptGrowth = true
- default:
- // Never condensed (CheckpointTranscriptStart == 0): any content means growth.
- hasTranscriptGrowth = transcriptBlobSize > 0
- }
- hasUncommittedFiles := len(state.FilesTouched) > 0
-
- logging.Debug(
- logCtx, "sessionHasNewContent: transcript size check",
- slog.String("session_id", state.SessionID),
- slog.Int64("transcript_blob_size", transcriptBlobSize),
- slog.Int64("checkpoint_transcript_size", state.CheckpointTranscriptSize),
- slog.Bool("has_transcript_growth", hasTranscriptGrowth),
- slog.Bool("has_uncommitted_files", hasUncommittedFiles),
- )
-
- if !hasTranscriptGrowth && !hasUncommittedFiles {
- return false, nil // No new content and no carry-forward files
- }
-
- // Check if staged files overlap with session's files with content-aware matching.
- // This is primarily for PrepareCommitMsg; in PostCommit, stagedFiles is nil/empty.
- if len(opts.stagedFiles) > 0 {
- result := stagedFilesOverlapWithContent(ctx, repo, tree, opts.stagedFiles, state.FilesTouched)
- logging.Debug(
- logCtx, "sessionHasNewContent: staged files overlap check",
- slog.String("session_id", state.SessionID),
- slog.Int("staged_files", len(opts.stagedFiles)),
- slog.Bool("result", result),
- )
- return result, nil
- }
-
- // No staged files - either PostCommit context or edge case.
- //
- // For recently-active IDLE sessions, the staged set is already gone by
- // PostCommit, but carry-forward files from the just-ended turn must still
- // count as "new content". The caller performs the stricter committed-file
- // overlap check before actually condensing, which prevents false positives
- // from unrelated commits.
- //
- // Stale IDLE sessions and ENDED sessions should NOT take this path. Those
- // states may retain FilesTouched from older work, and treating that alone as
- // fresh content would incorrectly condense old sessions into unrelated commits.
- if hasUncommittedFiles && state.Phase == session.PhaseIdle && isRecentInteraction(state.LastInteractionTime) {
- logging.Debug(
- logCtx, "sessionHasNewContent: no staged files, returning true due to recent idle carry-forward files",
- slog.String("session_id", state.SessionID),
- slog.Bool("has_transcript_growth", hasTranscriptGrowth),
- slog.Bool("has_uncommitted_files", hasUncommittedFiles),
- slog.String("phase", string(state.Phase)),
- )
- return true, nil
- }
-
- // No staged files and no carry-forward files: transcript growth is the only
- // remaining signal that there is new session content to condense.
- logging.Debug(
- logCtx, "sessionHasNewContent: no staged files, returning transcript growth",
- slog.String("session_id", state.SessionID),
- slog.Bool("has_transcript_growth", hasTranscriptGrowth),
- slog.Bool("has_uncommitted_files", hasUncommittedFiles),
- )
- return hasTranscriptGrowth, nil
-}
-
-// sessionHasNewContentFromLiveTranscript checks if a session has new content
-// by examining the live transcript file. This is used when no shadow branch exists
-// (i.e., no Stop has happened yet) but the agent may have done work.
-//
-// Returns true if:
-// 1. The transcript has grown since the last condensation, AND
-// 2. The new transcript portion contains file modifications, AND
-// 3. At least one modified file overlaps with the currently staged files
-//
-// The overlap check ensures we don't add checkpoint trailers to commits that are
-// unrelated to the agent's recent changes.
-//
-// stagedFiles is the pre-computed list of staged files from the caller.
-//
-// This handles the scenario where the agent commits mid-session before Stop.
-func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(ctx context.Context, state *SessionState, stagedFiles []string) (bool, error) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- if !s.hasNewTranscriptWork(ctx, state) {
- return false, nil
- }
-
- // Prefer hook-populated files. If empty, extract from transcript directly —
- // hasNewTranscriptWork already called PrepareTranscript, so we bypass
- // resolveFilesTouched (which would prepare again) and extract directly.
- modifiedFiles := state.FilesTouched
- if len(modifiedFiles) == 0 {
- modifiedFiles = s.extractModifiedFilesFromLiveTranscript(ctx, state, state.CheckpointTranscriptStart)
- }
- if len(modifiedFiles) == 0 {
- return false, nil
- }
-
- logging.Debug(
- logCtx, "live transcript check: found file modifications",
- slog.String("session_id", state.SessionID),
- slog.Int("modified_files", len(modifiedFiles)),
- )
-
- logging.Debug(
- logCtx, "live transcript check: comparing staged vs modified",
- slog.String("session_id", state.SessionID),
- slog.Int("staged_files", len(stagedFiles)),
- slog.Int("modified_files", len(modifiedFiles)),
- )
-
- if !hasOverlappingFiles(stagedFiles, modifiedFiles) {
- logging.Debug(
- logCtx, "live transcript check: no overlap between staged and modified files",
- slog.String("session_id", state.SessionID),
- )
- return false, nil // No overlap - staged files are unrelated to agent's work
- }
-
- return true, nil
-}
-
-// resolveFilesTouched returns the file list for a session.
-// Prefers hook-populated state.FilesTouched, falls back to transcript extraction.
-// All call sites that need "what files did the agent touch?" should use this.
-//
-// Handles PrepareTranscript internally before falling back to extraction,
-// so callers don't need to prepare the transcript first.
-func (s *ManualCommitStrategy) resolveFilesTouched(ctx context.Context, state *SessionState) []string {
- if len(state.FilesTouched) > 0 {
- result := make([]string, len(state.FilesTouched))
- copy(result, state.FilesTouched)
- return result
- }
-
- // Prepare transcript before extraction (e.g., OpenCode `opencode export`).
- prepareTranscriptForState(ctx, state)
-
- return s.extractModifiedFilesFromLiveTranscript(ctx, state, state.CheckpointTranscriptStart)
-}
-
-// hasNewTranscriptWork checks if the agent has done work since the last condensation.
-// Uses agent-delegated GetTranscriptPosition() — does NOT do file extraction.
-// All call sites that need "has the agent done new work?" should use this.
-//
-// Returns false if: no transcript path, unknown agent type, agent doesn't implement
-// TranscriptAnalyzer, or GetTranscriptPosition fails. This is intentional fail-safe
-// behavior: callers treat false as "no new work detected", which conservatively
-// skips condensation on errors.
-func (s *ManualCommitStrategy) hasNewTranscriptWork(ctx context.Context, state *SessionState) bool {
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- if state.TranscriptPath == "" || state.AgentType == "" {
- return false
- }
-
- // Re-resolve transcript path — handles agents that relocate transcripts mid-session.
- if _, resolveErr := resolveTranscriptPath(state); resolveErr != nil {
- logging.Debug(
- logCtx, "hasNewTranscriptWork: transcript path resolution failed",
- slog.String("session_id", state.SessionID),
- slog.Any("error", resolveErr),
- )
- return false
- }
-
- ag, err := agent.GetByAgentType(state.AgentType)
- if err != nil {
- return false
- }
-
- // Ensure transcript file is up-to-date (OpenCode creates/refreshes it via `opencode export`).
- // Only wait for flush when the session is active — for idle/ended sessions the
- // transcript is already fully flushed (the Stop hook completed the flush).
- if state.Phase.IsActive() {
- if preparer, ok := agent.AsTranscriptPreparer(ag); ok {
- if prepErr := preparer.PrepareTranscript(ctx, state.TranscriptPath); prepErr != nil {
- logging.Debug(
- logCtx, "prepare transcript failed",
- slog.String("session_id", state.SessionID),
- slog.String("agent_type", string(state.AgentType)),
- slog.String("transcript_path", state.TranscriptPath),
- slog.Any("error", prepErr),
- )
- }
- }
- }
- analyzer, ok := agent.AsTranscriptAnalyzer(ag)
- if !ok {
- return false
- }
-
- currentPos, err := analyzer.GetTranscriptPosition(state.TranscriptPath)
- if err != nil {
- logging.Debug(
- logCtx, "hasNewTranscriptWork: GetTranscriptPosition failed",
- slog.String("session_id", state.SessionID),
- slog.String("transcript_path", state.TranscriptPath),
- slog.Any("error", err),
- )
- return false
- }
-
- if currentPos <= state.CheckpointTranscriptStart {
- logging.Debug(
- logCtx, "hasNewTranscriptWork: no new content",
- slog.String("session_id", state.SessionID),
- slog.Int("current_pos", currentPos),
- slog.Int("start_offset", state.CheckpointTranscriptStart),
- )
- return false
- }
-
- return true
-}
-
-// extractModifiedFilesFromLiveTranscript extracts modified files from the live transcript
-// (including subagent transcripts) starting from the given offset, and normalizes them
-// to repo-relative paths. Returns the normalized file list.
-//
-// Callers must ensure the transcript is prepared (e.g., via prepareTranscriptForState
-// or hasNewTranscriptWork) before calling this function.
-func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx context.Context, state *SessionState, offset int) []string {
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- if state.TranscriptPath == "" || state.AgentType == "" {
- return nil
- }
-
- // Re-resolve transcript path — handles agents that relocate transcripts mid-session.
- if _, resolveErr := resolveTranscriptPath(state); resolveErr != nil {
- logging.Debug(
- logCtx, "extractModifiedFilesFromLiveTranscript: transcript path resolution failed",
- slog.String("session_id", state.SessionID),
- slog.Any("error", resolveErr),
- )
- return nil
- }
-
- ag, err := agent.GetByAgentType(state.AgentType)
- if err != nil {
- return nil
- }
-
- analyzer, ok := agent.AsTranscriptAnalyzer(ag)
- if !ok {
- return nil
- }
-
- var modifiedFiles []string
-
- // Prefer SubagentAwareExtractor for agents that support it, to include
- // subagent transcript files in a single pass. Fall back to basic extraction.
- if subagentExtractor, subOk := agent.AsSubagentAwareExtractor(ag); subOk {
- subagentsDir := filepath.Join(filepath.Dir(state.TranscriptPath), state.SessionID, "subagents")
- transcriptData, readErr := os.ReadFile(state.TranscriptPath)
- if readErr != nil {
- logging.Debug(
- logCtx, "extractModifiedFilesFromLiveTranscript: failed to read transcript",
- slog.String("session_id", state.SessionID),
- slog.String("error", readErr.Error()),
- )
- } else {
- allFiles, extractErr := subagentExtractor.ExtractAllModifiedFiles(transcriptData, offset, subagentsDir)
- if extractErr != nil {
- logging.Debug(
- logCtx, "extractModifiedFilesFromLiveTranscript: extraction failed",
- slog.String("session_id", state.SessionID),
- slog.String("error", extractErr.Error()),
- )
- } else {
- modifiedFiles = allFiles
- }
- }
- } else {
- files, _, err := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, offset)
- if err != nil {
- logging.Debug(
- logCtx, "extractModifiedFilesFromLiveTranscript: main transcript extraction failed",
- slog.String("transcript_path", state.TranscriptPath),
- slog.Any("error", err),
- )
- } else {
- modifiedFiles = files
- }
- }
-
- if len(modifiedFiles) == 0 {
- return nil
- }
-
- // Normalize to repo-relative paths.
- // Transcript tool_use entries contain absolute paths (e.g., /Users/alex/project/src/main.go)
- // but getStagedFiles/committedFiles use repo-relative paths (e.g., src/main.go).
- basePath := state.WorktreePath
- if basePath == "" {
- if wp, wpErr := paths.WorktreeRoot(ctx); wpErr == nil {
- basePath = wp
- }
- }
- if basePath != "" {
- normalized := make([]string, 0, len(modifiedFiles))
- for _, f := range modifiedFiles {
- if rel := paths.ToRelativePath(f, basePath); rel != "" {
- normalized = append(normalized, filepath.ToSlash(rel))
- } else if len(f) > 0 && !filepath.IsAbs(f) && f[0] != '/' {
- // Already relative — keep as-is
- normalized = append(normalized, filepath.ToSlash(f))
- }
- // else: absolute path outside repo — skip. These can't match
- // committed file paths (which are repo-relative) and would
- // create phantom carry-forward branches.
- }
- modifiedFiles = normalized
- }
-
- return modifiedFiles
-}
-
-// warnIfAttributionDiverged prints at most one stderr warning per call and
-// marks every divergent session as notified so subsequent invocations stay
-// silent until the next successful condensation (or reconcile) realigns
-// attribution and clears the flag via State.RealignAttributionBase.
-//
-// Divergence arises when the migrate path advances BaseCommit to a new HEAD
-// but intentionally leaves AttributionBaseCommit pinned (e.g., after a pull
-// or git reset to an unrelated commit). Writing to stderrWriter surfaces the
-// message in the user's terminal during prepare-commit-msg, not the agent's
-// transcript — stderr from the hook is TTY-bound to the invoking process.
-func (s *ManualCommitStrategy) warnIfAttributionDiverged(ctx context.Context, sessions []*SessionState) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- printed := false
- for _, sess := range sessions {
- if sess.AttributionBaseCommit == "" ||
- sess.AttributionBaseCommit == sess.BaseCommit ||
- sess.DivergenceNoticeShown {
- continue
- }
- if !printed {
- fmt.Fprintln(stderrWriter, "trace: session attribution diverged after recent history movement; figures may be off until next checkpoint")
- printed = true
- }
- sess.DivergenceNoticeShown = true
- if err := s.saveSessionState(ctx, sess); err != nil {
- logging.Warn(logCtx, "failed to save divergence notice flag",
- slog.String("session_id", sess.SessionID),
- slog.String("error", err.Error()))
- }
- }
-}
-
-// tryAgentCommitFastPath skips content detection for mid-turn agent commits.
-// Returns true if the fast path was taken (trailer added or attempt made),
-// false if the caller should continue with normal content detection.
-//
-// The fast path activates when an ACTIVE session exists and either:
-// - No TTY is available (agent subprocess, CI), or
-// - commit_linking="always" (user opted into auto-linking — needed because
-// some agents like Gemini subagents commit mid-turn from processes that
-// have /dev/tty but can't respond to prompts, and content detection fails
-// since the shadow branch doesn't exist yet).
-func (s *ManualCommitStrategy) tryAgentCommitFastPath(ctx context.Context, commitMsgFile string, sessions []*SessionState, source string) bool {
- noTTY := !interactive.CanPromptInteractively()
- skipContentDetection := noTTY
- if !skipContentDetection {
- if stngs, err := settings.Load(ctx); err == nil {
- skipContentDetection = stngs.GetCommitLinking() == settings.CommitLinkingAlways
- }
- }
- if !skipContentDetection {
- return false
- }
- logCtx := logging.WithComponent(ctx, "checkpoint")
- activeSessions := 0
- emptyActiveSessions := 0
- for _, state := range sessions {
- if !state.Phase.IsActive() {
- continue
- }
- activeSessions++
- // Skip sessions that have no condensable content: no transcript path,
- // no tracked files, and no shadow branch data (StepCount == 0). These
- // would produce a Skipped result in CondenseSession, leaving the
- // Trace-Checkpoint trailer pointing to nothing on the metadata branch.
- // NOTE: conservative approximation of the skip gate in CondenseSession
- // (which checks extracted data, not raw state). Keep aligned.
- if state.TranscriptPath == "" && len(state.FilesTouched) == 0 && state.StepCount == 0 {
- emptyActiveSessions++
- logging.Debug(
- logCtx, "prepare-commit-msg: fast path skipping empty session",
- slog.String("session_id", state.SessionID),
- slog.String("agent_type", string(state.AgentType)),
- )
- continue
- }
- _ = s.addTrailerForAgentCommit(logCtx, commitMsgFile, state, source) //nolint:errcheck // always returns nil; kept for signature stability
- return true
- }
- // Log why fast path didn't fire — collect session phases for diagnostics.
- phases := make([]string, 0, len(sessions))
- for _, state := range sessions {
- phases = append(phases, string(state.Phase))
- }
- message := "prepare-commit-msg: fast path found no ACTIVE sessions"
- if activeSessions > 0 && emptyActiveSessions == activeSessions {
- message = "prepare-commit-msg: fast path skipped all ACTIVE sessions as empty"
- }
- logging.Debug(
- logCtx, message,
- slog.Bool("no_tty", noTTY),
- slog.Int("sessions", len(sessions)),
- slog.Int("active_sessions", activeSessions),
- slog.Int("empty_active_sessions", emptyActiveSessions),
- slog.Any("session_phases", phases),
- )
- return false
-}
-
-// addTrailerForAgentCommit handles the fast path when an agent is committing
-// (ACTIVE session + no TTY). Generates a checkpoint ID and adds the trailer
-// directly, bypassing content detection and interactive prompts.
-func (s *ManualCommitStrategy) addTrailerForAgentCommit(logCtx context.Context, commitMsgFile string, state *SessionState, source string) error { //nolint:unparam // kept for signature stability
- cpID, err := id.Generate()
- if err != nil {
- return nil //nolint:nilerr // Hook must be silent on failure
- }
-
- content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook
- if err != nil {
- return nil //nolint:nilerr // Hook must be silent on failure
- }
-
- message := string(content)
-
- // Don't add if trailer already exists
- if _, found := trailers.ParseCheckpoint(message); found {
- return nil
- }
-
- message = addCheckpointTrailer(message, cpID)
-
- logging.Info(
- logCtx, "prepare-commit-msg: agent commit trailer added",
- slog.String("strategy", "manual-commit"),
- slog.String("source", source),
- slog.String("checkpoint_id", cpID.String()),
- slog.String("session_id", state.SessionID),
- )
-
- if err := os.WriteFile(commitMsgFile, []byte(message), 0o600); err != nil { //nolint:gosec // path from git hook arg
- return nil //nolint:nilerr // Hook must be silent on failure
- }
- return nil
-}
-
-// addCheckpointTrailer adds the Trace-Checkpoint trailer to a commit message.
-// Delegates to trailers.AppendCheckpointTrailer for trailer-aware formatting.
-func addCheckpointTrailer(message string, checkpointID id.CheckpointID) string {
- return trailers.AppendCheckpointTrailer(message, checkpointID.String())
-}
-
-// addCheckpointTrailerWithComment adds the Trace-Checkpoint trailer with an explanatory comment.
-// The trailer is placed above the git comment block but below the user's message area,
-// with a comment explaining that the user can remove it if they don't want to link the commit
-// to the agent session. If prompt is non-empty, it's shown as context.
-func addCheckpointTrailerWithComment(message string, checkpointID id.CheckpointID, agentName, prompt string) string {
- trailer := trailers.CheckpointTrailerKey + ": " + checkpointID.String()
- commentLines := []string{
- "# Remove the Trace-Checkpoint trailer above if you don't want to link this commit to " + agentName + " session context.",
- }
- if prompt != "" {
- commentLines = append(commentLines, "# Last Prompt: "+prompt)
- }
- commentLines = append(commentLines, "# The trailer will be added to your next commit based on this branch.")
- comment := strings.Join(commentLines, "\n")
-
- lines := strings.Split(message, "\n")
-
- // Find where the git comment block starts (first # line)
- commentStart := -1
- for i, line := range lines {
- if strings.HasPrefix(line, "#") {
- commentStart = i
- break
- }
- }
-
- if commentStart == -1 {
- // No git comments, append trailer at the end
- return strings.TrimRight(message, "\n") + "\n\n" + trailer + "\n" + comment + "\n"
- }
-
- // Split into user content and git comments
- userContent := strings.Join(lines[:commentStart], "\n")
- gitComments := strings.Join(lines[commentStart:], "\n")
-
- // Build result: user content, blank line, trailer, comment, blank line, git comments
- userContent = strings.TrimRight(userContent, "\n")
- if userContent == "" {
- // No user content yet - leave space for them to type, then trailer
- // Two newlines: first for user's message line, second for blank separator
- return "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
- }
- return userContent + "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
-}
-
-// InitializeSession creates session state for a new session or updates an existing one.
-// This implements the optional SessionInitializer interface.
-// Called during UserPromptSubmit to allow git hooks to detect active sessions.
-//
-// If the session already exists and HEAD has moved (e.g., user committed), updates
-// BaseCommit to the new HEAD so future checkpoints go to the correct shadow branch.
-//
-// If there's an existing shadow branch with commits from a different session ID,
-// returns a SessionIDConflictError to prevent orphaning existing session work.
-//
-// agentType is the human-readable name of the agent (e.g., "Claude Code").
-// transcriptPath is the path to the live transcript file (for mid-session commit detection).
-// userPrompt is the user's prompt text (stored truncated as LastPrompt for display).
-// model is the LLM model identifier (e.g., "claude-sonnet-4-20250514"); empty if unknown.
-func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID string, agentType types.AgentType, transcriptPath string, userPrompt string, model string) error {
- repo, err := OpenRepository(ctx)
- if err != nil {
- return fmt.Errorf("failed to open git repository: %w", err)
- }
-
- // Resolve which agent actually owns this session.
- resolvedAgentType := resolveSessionAgentType(ctx, sessionID, agentType, transcriptPath)
-
- // Check if session already exists
- state, err := s.loadSessionState(ctx, sessionID)
- if err != nil {
- return fmt.Errorf("failed to check session state: %w", err)
- }
-
- if state != nil && state.BaseCommit != "" {
- // Session is fully initialized — apply phase transition for TurnStart.
- if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
- logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",
- slog.String("session_id", sessionID),
- slog.String("error", transErr.Error()))
- }
-
- // Generate a new TurnID for each turn (correlates carry-forward checkpoints)
- turnID, err := id.Generate()
- if err != nil {
- return fmt.Errorf("failed to generate turn ID: %w", err)
- }
- state.TurnID = turnID.String()
-
- // Update AgentType when it isn't set yet, or when the transcript path
- // proves we're a different agent than the one stored.
- if state.AgentType == "" && resolvedAgentType != "" {
- state.AgentType = resolvedAgentType
- } else if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed {
- logging.Info(logging.WithComponent(ctx, "hooks"), "corrected session agent type from transcript path",
- slog.String("session_id", sessionID),
- slog.String("from", string(state.AgentType)),
- slog.String("to", string(corrected)),
- slog.String("transcript_path", transcriptPath))
- state.AgentType = corrected
- }
-
- // Update ModelName if provided (model can change between turns)
- if model != "" {
- state.ModelName = model
- }
-
- // Update LastPrompt on every turn so condensation always has the current prompt
- if userPrompt != "" {
- state.LastPrompt = truncatePromptForStorage(userPrompt)
- }
-
- // Update transcript path if provided (may change on session resume)
- if transcriptPath != "" && state.TranscriptPath != transcriptPath {
- state.TranscriptPath = transcriptPath
- }
-
- // ORDERING: attribution runs BEFORE migrate to use the pre-migration
- // BaseCommit as the base tree (preserving correct agent-line counts when
- // HEAD moved between turns via pull/rebase). Migrate runs BEFORE the
- // LastCheckpointID clear so the reconcile guard can read the checkpoint ID.
- //
- // Sequence: attribution → migrate → clear
- //
- // 1. Attribution uses state.BaseCommit to locate the shadow branch and
- // base tree. Running it before migrate ensures it diffs against the
- // original base, not the post-migration HEAD.
- // 2. Migrate/reconcile reads state.LastCheckpointID — clearing it first
- // would prevent the reconcile path from ever firing at turn start.
-
- // Calculate attribution at prompt start (BEFORE agent makes any changes)
- // This captures user edits since the last checkpoint (or base commit for first prompt).
- // IMPORTANT: Always calculate attribution, even for the first checkpoint, to capture
- // user edits made before the first prompt. The inner CalculatePromptAttribution handles
- // nil lastCheckpointTree by falling back to baseTree.
- promptAttr := s.calculatePromptAttributionAtStart(ctx, repo, state)
- state.PendingPromptAttribution = &promptAttr
-
- // Check if HEAD has moved (user pulled/rebased or committed)
- // migrateShadowBranchIfNeeded handles renaming the shadow branch and updating state.BaseCommit
- _, reconciled, err := s.migrateShadowBranchIfNeeded(ctx, repo, state)
- if err != nil {
- return fmt.Errorf("failed to check/migrate shadow branch: %w", err)
- }
- if reconciled {
- // Reconcile advanced BaseCommit + AttributionBaseCommit to HEAD (the
- // known checkpoint we reset to). The attribution just computed is
- // against the stale pre-reset base and would count discarded-history
- // edits as churn. Recompute against the new base so the next
- // checkpoint sees accurate user-delta.
- recomputed := s.calculatePromptAttributionAtStart(ctx, repo, state)
- state.PendingPromptAttribution = &recomputed
- }
-
- // Clear checkpoint IDs on every new prompt.
- // LastCheckpointID is set during PostCommit, cleared at new prompt.
- // TurnCheckpointIDs tracks mid-turn checkpoints for stop-time finalization.
- state.LastCheckpointID = ""
- state.TurnCheckpointIDs = nil
-
- if err := s.saveSessionState(ctx, state); err != nil {
- return fmt.Errorf("failed to update session state: %w", err)
- }
- return nil
- }
- // If state exists but BaseCommit is empty, it's a partial state from concurrent warning
- // Continue below to properly initialize it
-
- // Initialize new session
- state, err = s.initializeSession(ctx, repo, sessionID, resolvedAgentType, transcriptPath, userPrompt, model)
- if err != nil {
- return fmt.Errorf("failed to initialize session: %w", err)
- }
-
- // Apply phase transition: new session starts as ACTIVE.
- if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
- logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",
- slog.String("session_id", sessionID),
- slog.String("error", transErr.Error()))
- }
-
- // Calculate attribution for pre-prompt edits
- // This captures any user edits made before the first prompt
- promptAttr := s.calculatePromptAttributionAtStart(ctx, repo, state)
- state.PendingPromptAttribution = &promptAttr
- if err = s.saveSessionState(ctx, state); err != nil {
- return fmt.Errorf("failed to save attribution: %w", err)
- }
-
- logging.Info(logging.WithComponent(ctx, "hooks"), "initialized shadow session",
- slog.String("session_id", sessionID))
- return nil
-}
-
-// calculatePromptAttributionAtStart calculates attribution at prompt start (before agent runs).
-// This captures user changes since the last checkpoint - no filtering needed since
-// the agent hasn't made any changes yet.
-//
-// IMPORTANT: This reads from the worktree (not staging area) to match what WriteTemporary
-// captures in checkpoints. If we read staged content but checkpoints capture worktree content,
-// unstaged changes would be in the checkpoint but not counted in PromptAttribution, causing
-// them to be incorrectly attributed to the agent later.
-func (s *ManualCommitStrategy) calculatePromptAttributionAtStart(
- ctx context.Context,
- repo *git.Repository,
- state *SessionState,
-) PromptAttribution {
- logCtx := logging.WithComponent(ctx, "attribution")
- nextCheckpointNum := state.StepCount + 1
- result := PromptAttribution{CheckpointNumber: nextCheckpointNum}
-
- // Get last checkpoint tree from shadow branch (if it exists).
- // For a new session (StepCount == 0), always use baseTree as the reference.
- // The shadow branch may contain checkpoints from OTHER concurrent sessions,
- // and using that tree would miss pre-session worktree dirt (e.g., .claude/settings.json)
- // because it appears unchanged when compared to another session's snapshot.
- var lastCheckpointTree *object.Tree
- if state.StepCount > 0 {
- // Existing session with prior checkpoints — use shadow branch as reference.
- shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- if ref, err := repo.Reference(refName, true); err != nil {
- logging.Debug(logCtx, "prompt attribution: no shadow branch",
- slog.String("shadow_branch", shadowBranchName))
- } else if shadowCommit, err := repo.CommitObject(ref.Hash()); err != nil {
- logging.Debug(logCtx, "prompt attribution: failed to get shadow commit",
- slog.String("shadow_ref", ref.Hash().String()),
- slog.String("error", err.Error()))
- } else if tree, err := shadowCommit.Tree(); err != nil {
- logging.Debug(logCtx, "prompt attribution: failed to get shadow tree",
- slog.String("error", err.Error()))
- } else {
- lastCheckpointTree = tree
- }
- }
- // For new sessions (StepCount == 0), lastCheckpointTree stays nil.
- // CalculatePromptAttribution falls back to baseTree, ensuring pre-session
- // worktree dirt is captured even when the shadow branch has other sessions' data.
-
- // Get base tree for agent lines calculation
- var baseTree *object.Tree
- if baseCommit, err := repo.CommitObject(plumbing.NewHash(state.BaseCommit)); err == nil {
- if tree, treeErr := baseCommit.Tree(); treeErr == nil {
- baseTree = tree
- } else {
- logging.Debug(logCtx, "prompt attribution: base tree unavailable",
- slog.String("error", treeErr.Error()))
- }
- } else {
- logging.Debug(logCtx, "prompt attribution: base commit unavailable",
- slog.String("base_commit", state.BaseCommit),
- slog.String("error", err.Error()))
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- logging.Debug(logCtx, "prompt attribution skipped: failed to get worktree",
- slog.String("error", err.Error()))
- return result
- }
-
- // Get worktree status to find ALL changed files
- status, err := worktree.Status()
- if err != nil {
- logging.Debug(logCtx, "prompt attribution skipped: failed to get worktree status",
- slog.String("error", err.Error()))
- return result
- }
-
- worktreeRoot := worktree.Filesystem.Root()
-
- // Build map of changed files with their worktree content
- // IMPORTANT: We read from worktree (not staging area) to match what WriteTemporary
- // captures in checkpoints. This ensures attribution is consistent.
- changedFiles := make(map[string]string)
- for filePath, fileStatus := range status {
- // Skip unmodified files
- if fileStatus.Worktree == git.Unmodified && fileStatus.Staging == git.Unmodified {
- continue
- }
- // Skip .trace metadata directory (session data, not user code)
- if strings.HasPrefix(filePath, paths.TraceMetadataDir+"/") || strings.HasPrefix(filePath, ".trace/") {
- continue
- }
-
- // Always read from worktree to match checkpoint behavior
- fullPath := filepath.Join(worktreeRoot, filePath)
- var content string
- if data, err := os.ReadFile(fullPath); err == nil { //nolint:gosec // filePath is from git worktree status
- // Use git's binary detection algorithm (matches getFileContent behavior).
- // Binary files are excluded from line-based attribution calculations.
- isBinary, binErr := binary.IsBinary(bytes.NewReader(data))
- if binErr == nil && !isBinary {
- content = string(data)
- }
- }
- // else: file deleted, unreadable, or binary - content remains empty string
-
- changedFiles[filePath] = content
- }
-
- // Use CalculatePromptAttribution from manual_commit_attribution.go
- result = CalculatePromptAttribution(baseTree, lastCheckpointTree, changedFiles, nextCheckpointNum)
-
- return result
-}
-
-// getStagedFiles returns a list of files staged for commit using native git CLI.
-// This is much faster than go-git's worktree.Status() which scans the entire
-// working tree. `git diff --cached --name-only` uses native git's optimized index
-// and filesystem monitors.
-//
-// Returns (non-nil empty slice, nil) when no files are staged — callers can
-// distinguish "no staged files" from "error resolving staged files" (nil, err).
-func getStagedFiles(ctx context.Context) ([]string, error) {
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return nil, fmt.Errorf("resolve worktree root: %w", err)
- }
-
- cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--name-only")
- cmd.Dir = repoRoot
- output, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("git diff --cached: %w", err)
- }
-
- staged := []string{}
- trimmed := strings.TrimSpace(string(output))
- // Normalize Windows line endings (\r\n) to Unix (\n) for cross-platform git output
- trimmed = strings.ReplaceAll(trimmed, "\r\n", "\n")
- for _, line := range strings.Split(trimmed, "\n") {
- if line != "" {
- staged = append(staged, filepath.ToSlash(line))
- }
- }
- return staged, nil
-}
-
-// getLastPrompt retrieves the most recent user prompt from a session's shadow branch.
-// Reads prompt.txt directly from the shadow branch tree instead of parsing the full
-// transcript (which involves token counting, context generation, etc.).
-// Returns empty string if no prompt can be retrieved.
-func (s *ManualCommitStrategy) getLastPrompt(ctx context.Context, repo *git.Repository, state *SessionState) string {
- prompts := readPromptsFromShadowBranch(ctx, repo, state)
- if len(prompts) == 0 {
- return ""
- }
- // Iterate backwards to find the last non-empty prompt.
- for i := len(prompts) - 1; i >= 0; i-- {
- cleaned := strings.TrimSpace(prompts[i])
- if cleaned != "" && !isOnlySeparators(cleaned) {
- return cleaned
- }
- }
- return ""
-}
-
-// readPromptsFromShadowBranch reads prompt.txt from the shadow branch tree.
-// Returns all prompts split on "\n\n---\n\n", or nil if prompt.txt is not available.
-func readPromptsFromShadowBranch(_ context.Context, repo *git.Repository, state *SessionState) []string {
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- ref, err := repo.Reference(refName, true)
- if err != nil {
- return nil
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return nil
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil
- }
-
- metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
- promptPath := metadataDir + "/" + paths.PromptFileName
- file, err := tree.File(promptPath)
- if err != nil {
- return nil
- }
-
- content, err := file.Contents()
- if err != nil {
- return nil
- }
-
- return splitPromptContent(content)
-}
-
-// HandleTurnEnd dispatches strategy-specific actions emitted when an agent turn ends.
-// The primary job is to finalize all checkpoints from this turn with the full transcript.
-//
-// During a turn, PostCommit writes provisional transcript data (whatever was available
-// at commit time). HandleTurnEnd replaces that with the complete session transcript
-// (from prompt to stop event), ensuring every checkpoint has the full context.
-//
-
-func (s *ManualCommitStrategy) HandleTurnEnd(ctx context.Context, state *SessionState) error { //nolint:unparam // error return is part of the hook contract; callers check it
- hadMidTurnCommits := len(state.TurnCheckpointIDs) > 0
-
- // Finalize all checkpoints from this turn with the full transcript.
- //
- // IMPORTANT: This is best-effort - errors are logged but don't fail the hook.
- // Failing here would prevent session cleanup and could leave state inconsistent.
- // The provisional transcript from PostCommit is already persisted, so the
- // checkpoint isn't lost - it just won't have the complete transcript.
- errCount := s.finalizeAllTurnCheckpoints(ctx, state)
- if errCount > 0 {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- logging.Warn(
- logCtx, "HandleTurnEnd completed with errors (best-effort)",
- slog.String("session_id", state.SessionID),
- slog.Int("error_count", errCount),
- )
- }
-
- // Advance CheckpointTranscriptStart to the actual transcript end after
- // mid-turn commits. When an agent commits mid-turn (e.g., Codex "commit/push"),
- // condensation records TotalTranscriptLines at commit time, but the agent
- // continues writing to the transcript (tool results, token counts, task_complete).
- // Without this fix, the next checkpoint's scoped transcript starts mid-turn,
- // including a tail of already-condensed content.
- //
- // Skip this when carry-forward is active. carryForwardToNewShadowBranch
- // intentionally resets CheckpointTranscriptStart to 0 so the next checkpoint
- // remains self-contained with the full transcript.
- if hadMidTurnCommits && state.TranscriptPath != "" && len(state.FilesTouched) == 0 {
- transcriptPath, resolveErr := resolveTranscriptPath(state)
- if resolveErr == nil {
- if ag, agErr := agent.GetByAgentType(state.AgentType); agErr == nil {
- if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok {
- if pos, posErr := analyzer.GetTranscriptPosition(transcriptPath); posErr == nil && pos > state.CheckpointTranscriptStart {
- logging.Debug(
- logging.WithComponent(ctx, "hooks"),
- "advancing CheckpointTranscriptStart to turn end after mid-turn commit",
- slog.String("session_id", state.SessionID),
- slog.Int("old_offset", state.CheckpointTranscriptStart),
- slog.Int("new_offset", pos),
- )
- state.CheckpointTranscriptStart = pos
- }
- }
- }
- }
- }
-
- return nil
-}
-
-// precomputeTranscriptBlobsForFinalize chunks + zlib-compresses the redacted
-// transcript once for reuse across every checkpoint in the turn. Returns nil
-// (without error) when the transcript is empty — downstream stores skip
-// transcript updates in that case, so precompute would only write a wasted
-// empty-chunk blob to the object store. On failure, logs a warning and
-// returns nil so the loop falls back to per-checkpoint chunking.
-func precomputeTranscriptBlobsForFinalize(ctx context.Context, repo *git.Repository, transcript redact.RedactedBytes, state *SessionState) *checkpoint.PrecomputedTranscriptBlobs {
- if transcript.Len() == 0 {
- return nil
- }
- _, span := perf.Start(ctx, "precompute_transcript_blobs")
- defer span.End()
- precomputed, err := checkpoint.PrecomputeTranscriptBlobs(ctx, repo, transcript, state.AgentType)
- if err != nil {
- logging.Warn(
- ctx, "finalize: precompute transcript blobs failed, falling back to per-checkpoint work",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()),
- )
- return nil
- }
- return precomputed
-}
-
-// finalizeAllTurnCheckpoints replaces the provisional transcript in each checkpoint
-// created during this turn with the full session transcript.
-//
-// This is called at turn end (stop hook). During the turn, PostCommit wrote whatever
-// transcript was available at commit time. Now we have the complete transcript and
-// replace it so every checkpoint has the full prompt-to-stop context.
-//
-// Returns the number of errors encountered (best-effort: continues processing on error).
-func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, state *SessionState) int {
- if len(state.TurnCheckpointIDs) == 0 {
- return 0 // No mid-turn commits to finalize
- }
-
- logCtx := logging.WithComponent(ctx, "checkpoint")
-
- logging.Info(
- logCtx, "finalizing turn checkpoints with full transcript",
- slog.String("session_id", state.SessionID),
- slog.Int("checkpoint_count", len(state.TurnCheckpointIDs)),
- )
-
- errCount := 0
-
- // Read full transcript from live transcript file, re-resolving the path if the
- // agent relocated it mid-session (e.g., Cursor CLI flat → nested layout change).
- if state.TranscriptPath == "" {
- logging.Warn(
- logCtx, "finalize: no transcript path, skipping",
- slog.String("session_id", state.SessionID),
- )
- state.TurnCheckpointIDs = nil
- return 1 // Count as error - all checkpoints will be skipped
- }
-
- transcriptPath, resolveErr := resolveTranscriptPath(state)
- if resolveErr != nil {
- logging.Warn(
- logCtx, "finalize: transcript path resolution failed, skipping",
- slog.String("session_id", state.SessionID),
- slog.Any("error", resolveErr),
- )
- state.TurnCheckpointIDs = nil
- return 1 // Count as error - all checkpoints will be skipped
- }
-
- fullTranscript, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath
- if err != nil || len(fullTranscript) == 0 {
- msg := "finalize: empty transcript, skipping"
- if err != nil {
- msg = "finalize: failed to read transcript, skipping"
- }
- logging.Warn(
- logCtx, msg,
- slog.String("session_id", state.SessionID),
- slog.String("transcript_path", state.TranscriptPath),
- slog.Any("error", err),
- )
- state.TurnCheckpointIDs = nil
- return 1 // Count as error - all checkpoints will be skipped
- }
-
- // Open repository (needed for shadow branch prompt reading and checkpoint store)
- repo, err := OpenRepository(ctx)
- if err != nil {
- logging.Warn(
- logCtx, "finalize: failed to open repository",
- slog.String("error", err.Error()),
- )
- state.TurnCheckpointIDs = nil
- return 1 // Count as error - all checkpoints will be skipped
- }
-
- prompts := readPromptsFromShadowBranch(ctx, repo, state)
- if len(prompts) == 0 {
- prompts = readPromptsFromFilesystem(ctx, state.SessionID)
- }
-
- // Redact secrets before writing. Checkpoint store methods require
- // pre-redacted in-memory transcript content from callers. The live
- // transcript on disk is still treated as raw/untrusted input, so redact it
- // here before anything is persisted to the metadata branch.
- //
- // On failure: drop the transcript but continue writing checkpoint metadata
- // (attribution, files touched, prompts). Hooks run without user interaction
- // so there is no retry path — preserving partial metadata is better than
- // losing everything. Persisting an unredacted transcript would be worse.
- _, redactSpan := perf.Start(logCtx, "redact_transcript")
- redactedTranscript, redactErr := redact.JSONLBytes(fullTranscript)
- redactSpan.End()
- if redactErr != nil {
- logging.Warn(
- logCtx, "finalize: transcript redaction failed, dropping transcript",
- slog.String("session_id", state.SessionID),
- slog.String("error", redactErr.Error()),
- )
- redactedTranscript = redact.RedactedBytes{}
- }
- for i, p := range prompts {
- prompts[i] = redact.String(p)
- }
-
- store := checkpoint.NewGitStore(repo)
- v2 := settings.CheckpointsVersion(logCtx) == 2
-
- // Evaluate v2 flag once before the loop to avoid re-reading settings per checkpoint
- var v2Store *checkpoint.V2GitStore
- if settings.IsCheckpointsV2Enabled(logCtx) {
- v2URL, err := remote.FetchURL(logCtx)
- if err != nil {
- logging.Debug(
- logCtx, "finalize: using origin for v2 store fetch remote",
- slog.String("error", err.Error()),
- )
- v2URL = originRemote
- }
- v2Store = checkpoint.NewV2GitStore(repo, v2URL)
- }
-
- precomputed := precomputeTranscriptBlobsForFinalize(logCtx, repo, redactedTranscript, state)
-
- // Resolve the agent and try external compaction once before the loop —
- // external compaction is invariant across checkpoints (same session/transcript).
- // Internal compaction must remain per-checkpoint due to per-checkpoint startLine.
- finalAg, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil; compactTranscriptForV2 handles nil
- var externalCompact []byte
- var isExternalAgent bool
- if v2Store != nil {
- externalCompact, isExternalAgent = compactAndRedactExternalTranscript(logCtx, finalAg, state)
- }
-
- // Update each checkpoint with the full transcript
- for _, cpIDStr := range state.TurnCheckpointIDs {
- cpID, parseErr := id.NewCheckpointID(cpIDStr)
- if parseErr != nil {
- logging.Warn(
- logCtx, "finalize: invalid checkpoint ID, skipping",
- slog.String("checkpoint_id", cpIDStr),
- slog.String("error", parseErr.Error()),
- )
- errCount++
- continue
- }
-
- updateOpts := checkpoint.UpdateCommittedOptions{
- CheckpointID: cpID,
- SessionID: state.SessionID,
- Transcript: redactedTranscript,
- Prompts: prompts,
- Agent: state.AgentType,
- PrecomputedBlobs: precomputed,
- }
-
- // Generate compact transcript for v2 /main
- if v2Store != nil {
- if isExternalAgent {
- updateOpts.CompactTranscript = externalCompact
- } else if redactedTranscript.Len() > 0 {
- updateOpts.CompactTranscript = finalizeInternalCompactTranscript(logCtx, finalAg, cpID, state, redactedTranscript, store, v2Store, v2)
- }
- }
-
- if !v2 {
- updateErr := store.UpdateCommitted(ctx, updateOpts)
- if updateErr != nil {
- logging.Warn(
- logCtx, "finalize: failed to update checkpoint",
- slog.String("checkpoint_id", cpIDStr),
- slog.String("error", updateErr.Error()),
- )
- errCount++
- continue
- }
- }
-
- if v2Store != nil {
- if v2Err := v2Store.UpdateCommitted(logCtx, updateOpts); v2Err != nil {
- attrs := []any{
- slog.String("checkpoint_id", cpIDStr),
- slog.String("error", v2Err.Error()),
- }
- if v2 {
- logging.Warn(logCtx, "finalize: failed to update checkpoint in v2", attrs...)
- errCount++
- continue
- }
- logging.Warn(logCtx, "v2 dual-write update failed", attrs...)
- }
- }
-
- logging.Info(
- logCtx, "finalize: checkpoint updated with full transcript",
- slog.String("checkpoint_id", cpIDStr),
- slog.String("session_id", state.SessionID),
- )
- }
-
- // Clear turn checkpoint IDs. Do NOT update CheckpointTranscriptStart here — it was
- // already set correctly by PostCommit: condenseAndUpdateState sets it to the total
- // transcript lines when condensing, and carryForwardToNewShadowBranch resets it to 0
- // when carry-forward is active. Overwriting here would break carry-forward by making
- // sessionHasNewContent think the transcript is fully consumed (no growth).
- state.TurnCheckpointIDs = nil
-
- return errCount
-}
-
-// finalizeInternalCompactTranscript resolves the per-checkpoint startLine and
-// produces the compact transcript for built-in agents during finalization.
-func finalizeInternalCompactTranscript(
- ctx context.Context,
- ag agent.Agent,
- cpID id.CheckpointID,
- state *SessionState,
- redactedTranscript redact.RedactedBytes,
- store *checkpoint.GitStore,
- v2Store *checkpoint.V2GitStore,
- v2 bool,
-) []byte {
- var (
- content *checkpoint.SessionContent
- readErr error
- )
- if v2 {
- content, readErr = v2Store.ReadSessionContentByID(ctx, cpID, state.SessionID)
- } else {
- content, readErr = store.ReadSessionContentByID(ctx, cpID, state.SessionID)
- }
- startLine := 0
- if readErr == nil && content != nil {
- startLine = content.Metadata.GetTranscriptStart()
- } else {
- errMsg := "unknown"
- if readErr != nil {
- errMsg = readErr.Error()
- }
- logging.Debug(
- ctx, "finalize: failed to read checkpoint metadata, using full transcript for compact output",
- slog.String("checkpoint_id", cpID.String()),
- slog.String("session_id", state.SessionID),
- slog.String("error", errMsg),
- )
- }
- return compactTranscriptForV2(ctx, ag, redactedTranscript, startLine)
-}
-
-// filesChangedInCommit returns the set of files changed in a commit using git diff-tree.
-// Uses the git CLI for faster performance vs go-git tree walks (lower constant factors).
-// Falls back to go-git tree walk if git diff-tree fails, since an empty result would
-// break downstream condensation and carry-forward logic.
-func filesChangedInCommit(ctx context.Context, repoDir string, commit *object.Commit, headTree, parentTree *object.Tree) map[string]struct{} {
- var parentHash string
- if commit.NumParents() > 0 {
- parentHash = commit.ParentHashes[0].String()
- }
- result, err := gitops.DiffTreeFiles(ctx, repoDir, parentHash, commit.Hash.String())
- if err != nil {
- logging.Warn(
- ctx, "post-commit: git diff-tree failed, falling back to tree walk",
- slog.String("commit", commit.Hash.String()),
- slog.String("error", err.Error()),
- )
- return filesChangedInCommitFallback(ctx, headTree, parentTree)
- }
- return result
-}
-
-// filesChangedInCommitFallback uses go-git tree walks to compute changed files.
-// Slower than git diff-tree but doesn't depend on an external process.
-func filesChangedInCommitFallback(ctx context.Context, headTree, parentTree *object.Tree) map[string]struct{} {
- files, err := getAllChangedFilesBetweenTreesSlow(ctx, parentTree, headTree)
- if err != nil {
- logging.Warn(
- ctx, "post-commit: tree walk fallback also failed; condensation and carry-forward may be affected",
- slog.String("error", err.Error()),
- )
- return make(map[string]struct{})
- }
- result := make(map[string]struct{}, len(files))
- for _, f := range files {
- result[f] = struct{}{}
- }
- return result
-}
-
-// subtractFiles returns files that are NOT in the exclude set.
-func subtractFiles(files []string, exclude map[string]struct{}) []string {
- var remaining []string
- for _, f := range files {
- if _, excluded := exclude[f]; !excluded {
- remaining = append(remaining, f)
- }
- }
- return remaining
-}
-
-// carryForwardToNewShadowBranch creates a new shadow branch at the current HEAD
-// containing the remaining uncommitted files and all session metadata.
-// This enables the next commit to get its own unique checkpoint.
-func (s *ManualCommitStrategy) carryForwardToNewShadowBranch(
- ctx context.Context,
- repo *git.Repository,
- state *SessionState,
- remainingFiles []string,
-) {
- logCtx := logging.WithComponent(ctx, "checkpoint")
- start := time.Now()
- store := checkpoint.NewGitStore(repo)
-
- // Don't include metadata directory in carry-forward. The carry-forward branch
- // only needs to preserve file content for comparison - not the transcript.
- // Including the transcript would cause sessionHasNewContent to always return true
- // because CheckpointTranscriptStart is reset to 0 for carry-forward.
- writeCtx, carryForwardWriteSpan := perf.Start(ctx, "write_carry_forward_shadow")
- result, err := store.WriteTemporary(writeCtx, checkpoint.WriteTemporaryOptions{
- SessionID: state.SessionID,
- BaseCommit: state.BaseCommit,
- WorktreeID: state.WorktreeID,
- ModifiedFiles: remainingFiles,
- MetadataDir: "",
- MetadataDirAbs: "",
- CommitMessage: "carry forward: uncommitted session files",
- IsFirstCheckpoint: false,
- })
- if err != nil {
- carryForwardWriteSpan.RecordError(err)
- carryForwardWriteSpan.End()
- logging.Warn(
- logCtx, "post-commit: carry-forward failed",
- slog.String("session_id", state.SessionID),
- slog.String("error", err.Error()),
- )
- return
- }
- carryForwardWriteSpan.End()
- duration := time.Since(start)
- if result.Skipped {
- logging.Debug(
- logCtx, "post-commit: carry-forward skipped (no changes)",
- slog.String("session_id", state.SessionID),
- )
- return
- }
-
- // Update state for the carry-forward checkpoint.
- // CheckpointTranscriptStart = 0 is intentional: each checkpoint is self-contained with
- // the full transcript. This trades storage efficiency for simplicity:
- // - Pro: Each checkpoint is independently readable without needing to stitch together
- // multiple checkpoints to understand the session history
- // - Con: For long sessions with multiple partial commits, each checkpoint includes
- // the full transcript, which could be large
- // An alternative would be incremental checkpoints (only new content since last condensation),
- // but this would complicate checkpoint retrieval and require careful tracking of dependencies.
- state.StepCount = 1
- state.CheckpointTranscriptStart = 0
- state.CompactTranscriptStart = 0
- state.CheckpointTranscriptSize = 0
- state.LastCheckpointID = ""
- // NOTE: TurnCheckpointIDs is intentionally NOT cleared here. Those checkpoint
- // IDs from earlier in the turn still need finalization with the full transcript
- // when HandleTurnEnd runs at stop time.
-
- logging.Info(
- logCtx, "post-commit: carried forward remaining files",
- slog.String("session_id", state.SessionID),
- slog.Int("remaining_files", len(remainingFiles)),
- )
- logging.Debug(
- logCtx, "carry-forward timings",
- slog.String("session_id", state.SessionID),
- slog.Int64("write_carry_forward_shadow_ms", duration.Milliseconds()),
- slog.Int("remaining_files", len(remainingFiles)),
- )
-}
-
-// resolveSessionAgentType picks the most reliable identifier for which agent
-// owns a session, given the agent whose hook is firing right now (callerAgentType),
-// an optional transcript path, and the SessionStart hint stored under the
-// session ID.
-func resolveSessionAgentType(ctx context.Context, sessionID string, callerAgentType types.AgentType, transcriptPath string) types.AgentType {
- if repoRoot, err := paths.WorktreeRoot(ctx); err == nil && transcriptPath != "" {
- if owner, ok := agent.ForTranscriptPath(transcriptPath, repoRoot); ok {
- return owner.Type()
- }
- }
- if hint := LoadAgentTypeHint(ctx, sessionID); hint != "" && hint != agent.AgentTypeUnknown {
- return hint
- }
- return callerAgentType
-}
-
-// correctSessionAgentType returns the agent type that owns transcriptPath when
-// it disagrees with currentType. Returns (currentType, false) if there's no
-// disagreement or no transcript signal.
-func correctSessionAgentType(ctx context.Context, currentType types.AgentType, transcriptPath string) (types.AgentType, bool) {
- if transcriptPath == "" {
- return currentType, false
- }
- repoRoot, err := paths.WorktreeRoot(ctx)
- if err != nil {
- return currentType, false
- }
- owner, ok := agent.ForTranscriptPath(transcriptPath, repoRoot)
- if !ok {
- return currentType, false
- }
- if owner.Type() == currentType {
- return currentType, false
- }
- return owner.Type(), true
-}
diff --git a/cli/strategy/manual_commit_hooks_2.go b/cli/strategy/manual_commit_hooks_2.go
new file mode 100644
index 0000000..4f22a9a
--- /dev/null
+++ b/cli/strategy/manual_commit_hooks_2.go
@@ -0,0 +1,774 @@
+package strategy
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+ "github.com/GrayCodeAI/trace/perf"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+func warnStaleEndedSessionsTo(ctx context.Context, count int, w io.Writer) {
+ commonDir, err := GetGitCommonDir(ctx)
+ if err != nil {
+ return // fail-open
+ }
+ warnDir := filepath.Join(commonDir, session.SessionStateDirName)
+ warnFile := filepath.Join(warnDir, staleEndedSessionWarnFile)
+ if info, statErr := os.Stat(warnFile); statErr == nil {
+ if time.Since(info.ModTime()) < staleEndedSessionWarnInterval {
+ return // rate-limited
+ }
+ }
+ //nolint:errcheck,gosec // G104: Best-effort warning — fail-open if file ops fail
+ os.MkdirAll(warnDir, 0o750)
+ //nolint:errcheck,gosec // G104: Best-effort sentinel file write
+ os.WriteFile(warnFile, []byte{}, 0o644)
+ fmt.Fprintf(
+ w,
+ "\ntrace: %d ended session(s) are accumulating and slowing down commits.\n"+
+ "Run 'trace doctor' to condense them and restore commit performance.\n\n",
+ count,
+ )
+}
+
+// activeSessionInteractionThreshold is the maximum age of LastInteractionTime
+// for an ACTIVE session to be considered genuinely active. 24h is generous
+// because LastInteractionTime only updates at TurnStart, not per-tool-call.
+const activeSessionInteractionThreshold = 24 * time.Hour
+
+// isRecentInteraction returns true if lastInteraction is non-nil and within
+// activeSessionInteractionThreshold of now.
+func isRecentInteraction(lastInteraction *time.Time) bool {
+ return lastInteraction != nil && time.Since(*lastInteraction) < activeSessionInteractionThreshold
+}
+
+func (h *postCommitActionHandler) HandleDiscardIfNoFiles(state *session.State) error {
+ if len(state.FilesTouched) == 0 {
+ logging.Debug(
+ logging.WithComponent(h.ctx, "checkpoint"), "post-commit: skipping empty ended session (no files to condense)",
+ slog.String("session_id", state.SessionID),
+ )
+ }
+ h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead)
+ return nil
+}
+
+func (h *postCommitActionHandler) HandleWarnStaleSession(_ *session.State) error {
+ // Not produced by EventGitCommit; no-op for exhaustiveness.
+ return nil
+}
+
+// During rebase/cherry-pick/revert operations, phase transitions are skipped entirely.
+//
+
+func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:unparam // error return is part of the hook contract; callers check it
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ _, openRepoSpan := perf.Start(ctx, "open_repository_and_head")
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ openRepoSpan.RecordError(err)
+ openRepoSpan.End()
+ return nil
+ }
+
+ // Get HEAD commit to check for trailer
+ head, err := repo.Head()
+ if err != nil {
+ openRepoSpan.RecordError(err)
+ openRepoSpan.End()
+ return nil
+ }
+
+ commit, err := repo.CommitObject(head.Hash())
+ if err != nil {
+ openRepoSpan.RecordError(err)
+ openRepoSpan.End()
+ return nil
+ }
+
+ // Check if commit has checkpoint trailer (ParseCheckpoint validates format)
+ checkpointID, found := trailers.ParseCheckpoint(commit.Message)
+ openRepoSpan.End()
+
+ if !found {
+ // No trailer — user removed it or it was never added (mid-turn commit).
+ // Still update BaseCommit for active sessions so future commits can match.
+ s.postCommitUpdateBaseCommitOnly(ctx, head)
+ return nil
+ }
+
+ _, findSessionsSpan := perf.Start(ctx, "find_sessions_for_worktree")
+ worktreePath, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ findSessionsSpan.RecordError(err)
+ findSessionsSpan.End()
+ return nil
+ }
+
+ // Find all active sessions for this worktree
+ sessions, err := s.findSessionsForWorktree(ctx, worktreePath)
+ findSessionsSpan.RecordError(err)
+ findSessionsSpan.End()
+
+ if err != nil || len(sessions) == 0 {
+ logging.Warn(
+ logCtx, "post-commit: no active sessions despite trailer",
+ slog.String("strategy", "manual-commit"),
+ slog.String("checkpoint_id", checkpointID.String()),
+ )
+ return nil //nolint:nilerr // Intentional: hooks must be silent on failure
+ }
+
+ // Build transition context
+ isRebase := isGitSequenceOperation(ctx)
+ transitionCtx := session.TransitionContext{
+ IsRebaseInProgress: isRebase,
+ }
+
+ if isRebase {
+ logging.Debug(
+ logCtx, "post-commit: rebase/sequence in progress, skipping phase transitions",
+ slog.String("strategy", "manual-commit"),
+ )
+ }
+
+ // Track shadow branch names and whether they can be deleted
+ shadowBranchesToDelete := make(map[string]struct{})
+ // Track active sessions that were NOT condensed — their shadow branches must be preserved
+ uncondensedActiveOnBranch := make(map[string]bool)
+
+ newHead := head.Hash().String()
+
+ // Pre-resolve HEAD tree and parent tree once for the trace PostCommit.
+ // These are immutable within this hook invocation and used by multiple
+ // per-session functions (filesOverlapWithContent, filesWithRemainingAgentChanges,
+ // calculateSessionAttributions).
+ _, resolveTreesSpan := perf.Start(ctx, "resolve_commit_trees")
+ var headTree *object.Tree
+ if t, err := commit.Tree(); err == nil {
+ headTree = t
+ }
+ var parentTree *object.Tree
+ if commit.NumParents() > 0 {
+ if parent, err := commit.Parent(0); err == nil {
+ if t, err := parent.Tree(); err == nil {
+ parentTree = t
+ }
+ }
+ }
+
+ committedFileSet := filesChangedInCommit(ctx, worktreePath, commit, headTree, parentTree)
+ resolveTreesSpan.End()
+
+ // Compute union of all sessions' FilesTouched for cross-session attribution,
+ // and count sessions whose tracked files overlap with committed files.
+ allAgentFiles := make(map[string]struct{})
+ sessionsWithCommittedFiles := 0
+ for _, state := range sessions {
+ if state.FullyCondensed && state.Phase == session.PhaseEnded {
+ continue
+ }
+ for _, f := range state.FilesTouched {
+ allAgentFiles[f] = struct{}{}
+ if _, ok := committedFileSet[f]; ok {
+ sessionsWithCommittedFiles++
+ break // count each session at most once
+ }
+ }
+ }
+
+ loopCtx, processSessionsLoop := perf.StartLoop(ctx, "process_sessions")
+ for _, state := range sessions {
+ // Skip fully-condensed ended sessions — no work remains.
+ // These sessions only persist for LastCheckpointID (amend trailer reuse).
+ if state.FullyCondensed && state.Phase == session.PhaseEnded {
+ continue
+ }
+ iterCtx, iterSpan := processSessionsLoop.Iteration(loopCtx)
+ s.postCommitProcessSession(iterCtx, repo, state, &transitionCtx, checkpointID,
+ head, commit, newHead, worktreePath, headTree, parentTree,
+ committedFileSet, shadowBranchesToDelete, uncondensedActiveOnBranch, allAgentFiles,
+ sessionsWithCommittedFiles)
+ iterSpan.End()
+ }
+ processSessionsLoop.End()
+
+ if err := s.updateCombinedAttributionForCheckpoint(ctx, repo, checkpointID, headTree, parentTree, worktreePath); err != nil {
+ logging.Warn(logCtx, "failed to update combined checkpoint attribution",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.String("error", err.Error()))
+ }
+
+ // Clean up shadow branches — only delete when ALL sessions on the branch are non-active
+ // or were condensed during this PostCommit.
+ _, cleanupBranchesSpan := perf.Start(ctx, "cleanup_shadow_branches")
+ for shadowBranchName := range shadowBranchesToDelete {
+ if uncondensedActiveOnBranch[shadowBranchName] {
+ logging.Debug(
+ logCtx, "post-commit: preserving shadow branch (active session exists)",
+ slog.String("shadow_branch", shadowBranchName),
+ )
+ continue
+ }
+ if err := deleteShadowBranch(ctx, repo, shadowBranchName); err != nil {
+ logging.Warn(logCtx, "failed to clean up shadow branch",
+ slog.String("shadow_branch", shadowBranchName),
+ slog.String("error", err.Error()))
+ } else {
+ logging.Info(
+ logCtx, "shadow branch deleted",
+ slog.String("strategy", "manual-commit"),
+ slog.String("shadow_branch", shadowBranchName),
+ )
+ }
+ }
+ cleanupBranchesSpan.End()
+
+ if stale := countWarnableStaleEndedSessions(repo, sessions); stale >= staleEndedSessionWarnThreshold {
+ warnStaleEndedSessions(ctx, stale)
+ }
+
+ return nil
+}
+
+// updateCombinedAttributionForCheckpoint computes holistic attribution across all sessions.
+// Instead of summing per-session numbers (which inflates totals because each session
+// independently counts the full commit), this diffs parent→HEAD once and classifies
+// lines as agent or human based on the union of all sessions' files_touched.
+func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(
+ ctx context.Context,
+ repo *git.Repository,
+ checkpointID id.CheckpointID,
+ headTree, parentTree *object.Tree,
+ repoDir string,
+) error {
+ logCtx := logging.WithComponent(ctx, "attribution")
+ store := checkpoint.NewGitStore(repo)
+
+ summary, err := store.ReadCommitted(ctx, checkpointID)
+ if err != nil {
+ return fmt.Errorf("reading checkpoint summary: %w", err)
+ }
+ if summary == nil || len(summary.Sessions) <= 1 {
+ return nil
+ }
+
+ // Collect union of files_touched from sessions that had real checkpoints (SaveStep ran).
+ // Sessions with checkpoints_count == 0 (e.g., commit-only sessions) use a fallback that
+ // includes ALL committed files, which would incorrectly classify human-created files as agent work.
+ agentFiles := make(map[string]struct{})
+ for i := range len(summary.Sessions) {
+ metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i)
+ if readErr != nil || metadata == nil {
+ continue
+ }
+ if metadata.CheckpointsCount == 0 {
+ continue // Skip sessions that used the filesTouched fallback
+ }
+ for _, f := range metadata.FilesTouched {
+ agentFiles[f] = struct{}{}
+ }
+ }
+
+ if len(agentFiles) == 0 {
+ return nil
+ }
+
+ // Get all files changed in this commit (parent → HEAD)
+ allChangedFiles, err := getAllChangedFiles(ctx, parentTree, headTree, repoDir, "", "")
+ if err != nil {
+ logging.Warn(logCtx, "combined attribution: failed to enumerate changed files",
+ slog.String("error", err.Error()))
+ return nil
+ }
+
+ // Classify each changed file as agent or human and count lines
+ var agentAdded, agentRemoved, humanAdded, humanRemoved int
+ for _, filePath := range allChangedFiles {
+ // Skip CLI/agent config metadata — not human or agent code work
+ if strings.HasPrefix(filePath, ".trace/") || strings.HasPrefix(filePath, paths.TraceMetadataDir+"/") ||
+ strings.HasPrefix(filePath, ".claude/") {
+ continue
+ }
+
+ parentContent := getFileContent(parentTree, filePath)
+ headContent := getFileContent(headTree, filePath)
+ _, added, removed := diffLines(parentContent, headContent)
+
+ if _, isAgent := agentFiles[filePath]; isAgent {
+ agentAdded += added
+ agentRemoved += removed
+ } else {
+ humanAdded += added
+ humanRemoved += removed
+ }
+ }
+
+ totalLinesChanged := agentAdded + agentRemoved + humanAdded + humanRemoved
+ totalCommitted := agentAdded + humanAdded
+
+ var agentPercentage float64
+ if totalLinesChanged > 0 {
+ agentPercentage = float64(agentAdded+agentRemoved) / float64(totalLinesChanged) * 100
+ }
+
+ combined := &checkpoint.InitialAttribution{
+ CalculatedAt: time.Now().UTC(),
+ AgentLines: agentAdded,
+ AgentRemoved: agentRemoved,
+ HumanAdded: humanAdded,
+ HumanRemoved: humanRemoved,
+ TotalCommitted: totalCommitted,
+ TotalLinesChanged: totalLinesChanged,
+ AgentPercentage: agentPercentage,
+ MetricVersion: 2,
+ }
+
+ logging.Info(
+ logCtx, "combined attribution calculated",
+ slog.String("checkpoint_id", checkpointID.String()),
+ slog.Int("sessions", len(summary.Sessions)),
+ slog.Int("agent_files", len(agentFiles)),
+ slog.Int("agent_lines", agentAdded),
+ slog.Int("human_added", humanAdded),
+ slog.Float64("agent_percentage", agentPercentage),
+ )
+
+ if err := store.UpdateCheckpointSummary(ctx, checkpointID, combined); err != nil {
+ return fmt.Errorf("persisting combined attribution: %w", err)
+ }
+
+ return nil
+}
+
+// postCommitProcessSession handles a single session within the PostCommit loop.
+// Pre-resolved git objects (headTree, parentTree) are shared across all sessions;
+// per-session shadow ref/tree are resolved once here and threaded through sub-calls.
+func (s *ManualCommitStrategy) postCommitProcessSession(
+ ctx context.Context,
+ repo *git.Repository,
+ state *SessionState,
+ transitionCtx *session.TransitionContext,
+ checkpointID id.CheckpointID,
+ head *plumbing.Reference,
+ commit *object.Commit,
+ newHead string,
+ repoDir string,
+ headTree, parentTree *object.Tree,
+ committedFileSet map[string]struct{},
+ shadowBranchesToDelete map[string]struct{},
+ uncondensedActiveOnBranch map[string]bool,
+ allAgentFiles map[string]struct{},
+ sessionsWithCommittedFiles int,
+) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+
+ // Pre-resolve shadow branch ref and tree for this session.
+ // These are read 4+ times across sessionHasNewContent, filesOverlapWithContent,
+ // CondenseSession, filesWithRemainingAgentChanges, and calculateSessionAttributions.
+ _, resolveShadowBranchSpan := perf.Start(ctx, "resolve_shadow_branch")
+ var shadowRef *plumbing.Reference
+ var shadowTree *object.Tree
+ if ref, refErr := repo.Reference(plumbing.NewBranchReferenceName(shadowBranchName), true); refErr == nil {
+ shadowRef = ref
+ if sc, scErr := repo.CommitObject(ref.Hash()); scErr == nil {
+ if st, stErr := sc.Tree(); stErr == nil {
+ shadowTree = st
+ }
+ }
+ }
+ resolveShadowBranchSpan.End()
+
+ // Check for new content (needed for TransitionContext and condensation).
+ // Fail-open: if content check errors, assume new content exists so we
+ // don't silently skip data that should have been condensed.
+ //
+ // For ACTIVE sessions: the commit has a checkpoint trailer (verified above),
+ // meaning PrepareCommitMsg already determined this commit is session-related.
+ // The trailer is only added when either:
+ // - No TTY (agent/subagent committing) — added unconditionally
+ // - TTY (human committing) — added after content detection confirmed agent work
+ // In both cases, PrepareCommitMsg already validated this commit. We trust
+ // that decision here. Transcript-based re-validation is unreliable because
+ // subagent transcripts may not be available yet (subagent still running).
+ _, checkContentSpan := perf.Start(ctx, "check_session_content")
+ var hasNew bool
+ if state.Phase.IsActive() {
+ hasNew = true
+ } else {
+ var contentErr error
+ hasNew, contentErr = s.sessionHasNewContent(ctx, repo, state, contentCheckOpts{shadowTree: shadowTree})
+ if contentErr != nil {
+ hasNew = true
+ logging.Debug(
+ logCtx, "post-commit: error checking session content, assuming new content",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", contentErr.Error()),
+ )
+ }
+ }
+ transitionCtx.HasFilesTouched = len(state.FilesTouched) > 0
+
+ // Save FilesTouched BEFORE TransitionAndLog — the handler's condensation
+ // clears it, but we need the original list for carry-forward computation.
+ // Only fall back to transcript extraction for ACTIVE sessions — IDLE/ENDED
+ // sessions have FilesTouched already populated by SaveStep/mergeFilesTouched.
+ var filesTouchedBefore []string
+ if state.Phase.IsActive() {
+ filesTouchedBefore = s.resolveFilesTouched(ctx, state)
+ } else if len(state.FilesTouched) > 0 {
+ filesTouchedBefore = make([]string, len(state.FilesTouched))
+ copy(filesTouchedBefore, state.FilesTouched)
+ }
+ checkContentSpan.End()
+
+ logging.Debug(
+ logCtx, "post-commit: carry-forward prep",
+ slog.String("session_id", state.SessionID),
+ slog.Bool("is_active", state.Phase.IsActive()),
+ slog.String("transcript_path", state.TranscriptPath),
+ slog.Int("files_touched_before", len(filesTouchedBefore)),
+ slog.Any("files", filesTouchedBefore),
+ )
+
+ // Run the state machine transition with handler for strategy-specific actions.
+ _, transitionAndCondenseSpan := perf.Start(ctx, "transition_and_condense")
+ handler := &postCommitActionHandler{
+ s: s,
+ ctx: ctx,
+ repo: repo,
+ checkpointID: checkpointID,
+ head: head,
+ commit: commit,
+ newHead: newHead,
+ repoDir: repoDir,
+ shadowBranchName: shadowBranchName,
+ shadowBranchesToDelete: shadowBranchesToDelete,
+ committedFileSet: committedFileSet,
+ hasNew: hasNew,
+ filesTouchedBefore: filesTouchedBefore,
+ headTree: headTree,
+ parentTree: parentTree,
+ shadowRef: shadowRef,
+ shadowTree: shadowTree,
+ allAgentFiles: allAgentFiles,
+ sessionsWithCommittedFiles: sessionsWithCommittedFiles,
+ }
+
+ if err := TransitionAndLog(ctx, state, session.EventGitCommit, *transitionCtx, handler); err != nil {
+ logging.Warn(logCtx, "post-commit action handler error",
+ slog.String("error", err.Error()))
+ }
+ transitionAndCondenseSpan.End()
+
+ // Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize
+ // with full transcript. IDLE/ENDED sessions already have complete transcripts.
+ // NOTE: This check runs AFTER TransitionAndLog updated the phase. It relies on
+ // ACTIVE + GitCommit → ACTIVE (phase stays ACTIVE). If that state machine
+ // transition ever changed, this guard would silently stop recording IDs.
+ if handler.condensed && state.Phase.IsActive() {
+ state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String())
+ }
+
+ // Carry forward remaining uncommitted files so the next commit gets its
+ // own checkpoint ID. This applies to ALL phases — if a user splits their
+ // commit across two `git commit` invocations, each gets a 1:1 checkpoint.
+ // Uses content-aware comparison: if user did `git add -p` and committed
+ // partial changes, the file still has remaining agent changes to carry forward.
+ _, carryForwardSpan := perf.Start(ctx, "carry_forward_files")
+ if handler.condensed {
+ remainingFiles := filesWithRemainingAgentChanges(ctx, repo, shadowBranchName, commit, filesTouchedBefore, committedFileSet, overlapOpts{
+ headTree: headTree,
+ shadowTree: shadowTree,
+ })
+ state.FilesTouched = remainingFiles
+ logging.Debug(
+ logCtx, "post-commit: carry-forward decision (content-aware)",
+ slog.String("session_id", state.SessionID),
+ slog.Int("files_touched_before", len(filesTouchedBefore)),
+ slog.Int("committed_files", len(committedFileSet)),
+ slog.Int("remaining_files", len(remainingFiles)),
+ slog.Any("remaining", remainingFiles),
+ slog.Any("committed_files", committedFileSet),
+ )
+ if len(remainingFiles) > 0 {
+ s.carryForwardToNewShadowBranch(ctx, repo, state, remainingFiles)
+ }
+
+ // Clear filesystem prompt.txt only when ALL files are committed.
+ // If carry-forward files remain, the prompt must persist so the next
+ // condensation (triggered by the next commit) can read it.
+ if len(state.FilesTouched) == 0 {
+ clearFilesystemPrompt(ctx, state.SessionID)
+ }
+ }
+ carryForwardSpan.End()
+
+ // Mark ENDED sessions as fully condensed when there's nothing left to do.
+ // Either we just condensed (no carry-forward remains) or there was never any
+ // new content. PostCommit will skip these on future commits; they persist only
+ // for LastCheckpointID (amend trailer restoration).
+ if state.Phase == session.PhaseEnded && len(state.FilesTouched) == 0 && (handler.condensed || !hasNew) {
+ state.FullyCondensed = true
+ }
+
+ // Save the updated state
+ _, saveSessionStateSpan := perf.Start(ctx, "save_session_state")
+ if err := s.saveSessionState(ctx, state); err != nil {
+ logging.Warn(logCtx, "failed to update session state",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()))
+ }
+ saveSessionStateSpan.End()
+
+ // Only preserve shadow branch for active sessions that were NOT condensed.
+ // Condensed sessions already have their data on trace/checkpoints/v1.
+ if state.Phase.IsActive() && !handler.condensed {
+ uncondensedActiveOnBranch[shadowBranchName] = true
+ }
+}
+
+// condenseAndUpdateState runs condensation for a session and updates state afterward.
+// Returns true if condensation succeeded.
+func (s *ManualCommitStrategy) condenseAndUpdateState(
+ ctx context.Context,
+ repo *git.Repository,
+ checkpointID id.CheckpointID,
+ state *SessionState,
+ head *plumbing.Reference,
+ shadowBranchName string,
+ shadowBranchesToDelete map[string]struct{},
+ committedFiles map[string]struct{},
+ opts ...condenseOpts,
+) bool {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ result, err := s.CondenseSession(ctx, repo, checkpointID, state, committedFiles, opts...)
+ if err != nil {
+ logging.Warn(
+ logCtx, "condensation failed",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()),
+ )
+ return false
+ }
+
+ if result.Skipped {
+ logging.Debug(
+ logCtx, "condensation skipped, session state unchanged",
+ slog.String("session_id", state.SessionID),
+ slog.String("checkpoint_id", checkpointID.String()),
+ )
+ return false
+ }
+
+ // Track this shadow branch for cleanup
+ shadowBranchesToDelete[shadowBranchName] = struct{}{}
+
+ // Update session state for the new base commit
+ newHead := head.Hash().String()
+ state.BaseCommit = newHead
+ state.RealignAttributionBase(newHead)
+ state.StepCount = 0
+ state.CheckpointTranscriptStart = result.TotalTranscriptLines
+ state.CompactTranscriptStart += result.CompactTranscriptLines
+ state.CheckpointTranscriptSize = int64(len(result.Transcript))
+
+ // Clear attribution tracking — condensation already used these values
+ state.PromptAttributions = nil
+ state.PendingPromptAttribution = nil
+ state.FilesTouched = nil
+
+ // NOTE: filesystem prompt.txt is NOT cleared here. The caller (PostCommit handler)
+ // decides whether to clear it based on carry-forward: if remaining files exist,
+ // the prompt must persist so the next condensation can read it.
+
+ // Save checkpoint ID so subsequent commits can reuse it (e.g., amend restores trailer).
+ // LastCheckpointCommitHash records the exact commit SHA so the reconcile path can
+ // distinguish a true reset (same SHA) from cherry-pick/rebase (same trailer, new SHA).
+ state.LastCheckpointID = checkpointID
+ state.LastCheckpointCommitHash = newHead
+
+ logging.Info(
+ logCtx, "session condensed",
+ slog.String("strategy", "manual-commit"),
+ slog.String("session_id", state.SessionID),
+ slog.String("checkpoint_id", result.CheckpointID.String()),
+ slog.Int("checkpoints_condensed", result.CheckpointsCount),
+ slog.Int("transcript_lines", result.TotalTranscriptLines),
+ )
+
+ return true
+}
+
+// updateBaseCommitIfChanged updates BaseCommit to newHead if it changed.
+// Only updates ACTIVE sessions. IDLE/ENDED sessions should NOT have their
+// BaseCommit updated, as this would cause them to be incorrectly associated
+// with a new shadow branch and potentially condensed on future commits.
+func (s *ManualCommitStrategy) updateBaseCommitIfChanged(ctx context.Context, state *SessionState, newHead string) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ // Only update ACTIVE sessions. IDLE/ENDED sessions are kept around for
+ // LastCheckpointID reuse and should not be advanced to HEAD.
+ if !state.Phase.IsActive() {
+ logging.Debug(
+ logCtx, "post-commit: updateBaseCommitIfChanged skipped non-active session",
+ slog.String("session_id", state.SessionID),
+ slog.String("phase", string(state.Phase)),
+ )
+ return
+ }
+ if state.BaseCommit != newHead {
+ state.BaseCommit = newHead
+ // Keep AttributionBaseCommit in sync to prevent stale base drift.
+ // Without this, a subsequent condensation would diff from the old base,
+ // inflating human_added with lines from unrelated prior commits.
+ state.RealignAttributionBase(newHead)
+ logging.Debug(
+ logCtx, "post-commit: updated BaseCommit and AttributionBaseCommit",
+ slog.String("session_id", state.SessionID),
+ slog.String("new_head", truncateHash(newHead)),
+ )
+ }
+}
+
+// postCommitUpdateBaseCommitOnly updates BaseCommit for all sessions on the current
+// worktree when a commit has no Trace-Checkpoint trailer. This prevents BaseCommit
+// from going stale, which would cause future PrepareCommitMsg calls to skip the
+// session (BaseCommit != currentHeadHash filter).
+//
+// Unlike the full PostCommit flow, this does NOT fire EventGitCommit or trigger
+// condensation — it only keeps BaseCommit in sync with HEAD.
+func (s *ManualCommitStrategy) postCommitUpdateBaseCommitOnly(ctx context.Context, head *plumbing.Reference) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ worktreePath, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return // Silent failure — hooks must be resilient
+ }
+
+ sessions, err := s.findSessionsForWorktree(ctx, worktreePath)
+ if err != nil || len(sessions) == 0 {
+ return
+ }
+
+ newHead := head.Hash().String()
+ for _, state := range sessions {
+ // Only update active sessions. Idle/ended sessions are kept around for
+ // LastCheckpointID reuse and should not be advanced to HEAD.
+ if !state.Phase.IsActive() {
+ continue
+ }
+ if state.BaseCommit != newHead {
+ logging.Debug(
+ logCtx, "post-commit (no trailer): updating BaseCommit and AttributionBaseCommit",
+ slog.String("session_id", state.SessionID),
+ slog.String("old_base", truncateHash(state.BaseCommit)),
+ slog.String("new_head", truncateHash(newHead)),
+ )
+ state.BaseCommit = newHead
+ // Keep AttributionBaseCommit in sync to prevent stale base drift.
+ // Without this, a subsequent condensation would diff from the old base,
+ // inflating human_added with lines from unrelated prior commits.
+ state.RealignAttributionBase(newHead)
+ if err := s.saveSessionState(ctx, state); err != nil {
+ logging.Warn(logCtx, "failed to update session state",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()))
+ }
+ }
+ }
+}
+
+// truncateHash safely truncates a git hash to 7 chars for logging.
+func truncateHash(h string) string {
+ if len(h) > 7 {
+ return h[:7]
+ }
+ return h
+}
+
+// filterSessionsWithNewContent returns sessions that have new transcript content
+// beyond what was already condensed.
+// Computes the staged files list once and reuses it across all sessions to avoid
+// redundant `git diff --cached` calls (previously called up to 3 times per session).
+func (s *ManualCommitStrategy) filterSessionsWithNewContent(ctx context.Context, repo *git.Repository, sessions []*SessionState) []*SessionState {
+ logCtx := logging.WithComponent(ctx, "manual-commit")
+ var result []*SessionState
+
+ // Compute staged files once for all sessions.
+ // On error, pass nil — sessionHasNewContent treats nil stagedFiles as
+ // "unavailable" and skips overlap checks, falling through to other heuristics.
+ stagedFiles, err := getStagedFiles(ctx)
+ if err != nil {
+ logging.Debug(
+ logCtx,
+ "filterSessionsWithNewContent: getStagedFiles failed, skipping overlap checks",
+ slog.String("error", err.Error()),
+ )
+ stagedFiles = nil
+ }
+
+ for _, state := range sessions {
+ // Skip fully-condensed ended sessions — no new content possible.
+ if state.FullyCondensed && state.Phase == session.PhaseEnded {
+ logging.Debug(
+ logCtx, "filterSessionsWithNewContent: skipping fully-condensed ended session",
+ slog.String("session_id", state.SessionID),
+ )
+ continue
+ }
+ hasNew, err := s.sessionHasNewContent(ctx, repo, state, contentCheckOpts{stagedFiles: stagedFiles})
+ if err != nil {
+ logging.Debug(
+ logCtx, "filterSessionsWithNewContent: error checking session, skipping it",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()),
+ )
+ continue
+ }
+ if !hasNew {
+ logging.Debug(
+ logCtx, "filterSessionsWithNewContent: session has no new content",
+ slog.String("session_id", state.SessionID),
+ slog.String("phase", string(state.Phase)),
+ slog.Int("files_touched", len(state.FilesTouched)),
+ )
+ }
+ if hasNew {
+ result = append(result, state)
+ }
+ }
+
+ return result
+}
+
+// contentCheckOpts holds pre-computed values for sessionHasNewContent to avoid
+// redundant work across multiple sessions in a single hook invocation.
+type contentCheckOpts struct {
+ // stagedFiles is the pre-computed list of staged files (from getStagedFiles).
+ // nil means staged files are unavailable (error or PostCommit context where
+ // files are already committed) — callers skip overlap checks and fall through
+ // to other heuristics (e.g., transcript growth).
+ // Non-nil empty means successfully resolved but no files are staged.
+ stagedFiles []string
+
+ // shadowTree, when non-nil, is used directly to avoid redundant shadow branch
+ // resolution (the shadow ref/commit/tree were already resolved by the caller).
+ shadowTree *object.Tree
+}
diff --git a/cli/strategy/manual_commit_hooks_3.go b/cli/strategy/manual_commit_hooks_3.go
new file mode 100644
index 0000000..6f3f445
--- /dev/null
+++ b/cli/strategy/manual_commit_hooks_3.go
@@ -0,0 +1,786 @@
+package strategy
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+)
+
+// sessionHasNewContent checks if a session has new transcript content
+// beyond what was already condensed.
+// The opts parameter provides pre-computed values to avoid redundant work.
+func (s *ManualCommitStrategy) sessionHasNewContent(ctx context.Context, repo *git.Repository, state *SessionState, opts contentCheckOpts) (bool, error) {
+ logCtx := logging.WithComponent(ctx, "manual-commit")
+
+ // Use cached shadow tree if provided
+ var tree *object.Tree
+ if opts.shadowTree != nil {
+ tree = opts.shadowTree
+ } else {
+ // Resolve shadow branch from repo
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no shadow branch, checking live transcript",
+ slog.String("session_id", state.SessionID),
+ slog.String("shadow_branch", shadowBranchName),
+ )
+ return s.sessionHasNewContentFromLiveTranscript(ctx, state, opts.stagedFiles)
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return false, fmt.Errorf("failed to get commit object: %w", err)
+ }
+
+ tree, err = commit.Tree()
+ if err != nil {
+ return false, fmt.Errorf("failed to get commit tree: %w", err)
+ }
+ }
+
+ // Look for transcript file — use blob size for fast growth check when possible.
+ // This avoids reading the full transcript content (potentially tens of MB) just
+ // to count lines, which was the main source of PostCommit latency with many sessions.
+ metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
+ var hasTranscriptFile bool
+ var transcriptBlobSize int64
+
+ if size, sizeErr := tree.Size(metadataDir + "/" + paths.TranscriptFileName); sizeErr == nil {
+ hasTranscriptFile = true
+ transcriptBlobSize = size
+ } else if size, sizeErr := tree.Size(metadataDir + "/" + paths.TranscriptFileNameLegacy); sizeErr == nil {
+ hasTranscriptFile = true
+ transcriptBlobSize = size
+ }
+
+ // If shadow branch exists but has no transcript (e.g., carry-forward from mid-session commit),
+ // check if the session has FilesTouched. Carry-forward sets FilesTouched with remaining files.
+ if !hasTranscriptFile {
+ if len(state.FilesTouched) > 0 {
+ // Shadow branch has files from carry-forward - check if staged files overlap
+ // AND have matching content (content-aware check).
+ if len(opts.stagedFiles) > 0 {
+ // PrepareCommitMsg context: check staged files overlap with content
+ result := stagedFilesOverlapWithContent(ctx, repo, tree, opts.stagedFiles, state.FilesTouched)
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no transcript, carry-forward with staged files",
+ slog.String("session_id", state.SessionID),
+ slog.Int("files_touched", len(state.FilesTouched)),
+ slog.Int("staged_files", len(opts.stagedFiles)),
+ slog.Bool("result", result),
+ )
+ return result, nil
+ }
+ // PostCommit context: no staged files, but we have carry-forward files.
+ // Return true and let the caller do the overlap check with committed files.
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no transcript, carry-forward without staged files (post-commit context)",
+ slog.String("session_id", state.SessionID),
+ slog.Int("files_touched", len(state.FilesTouched)),
+ )
+ return true, nil
+ }
+ // No transcript and no FilesTouched - fall back to live transcript check
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no transcript and no files touched, checking live transcript",
+ slog.String("session_id", state.SessionID),
+ )
+ return s.sessionHasNewContentFromLiveTranscript(ctx, state, opts.stagedFiles)
+ }
+
+ // Check if there's new content to condense. Two cases:
+ // 1. Transcript has grown since last condensation (new prompts/responses)
+ // 2. FilesTouched has files not yet committed (carry-forward scenario)
+ //
+ // For PrepareCommitMsg context, we verify staged files overlap with session's files
+ // using content-aware matching to detect reverted files.
+ // For PostCommit context, stagedFiles is nil/empty (files already committed),
+ // so we return true and let the caller do the overlap check via filesOverlapWithContent.
+
+ // Fast path: compare blob size against stored size from last condensation.
+ // This avoids reading the full transcript content just to count items.
+ var hasTranscriptGrowth bool
+ switch {
+ case state.CheckpointTranscriptSize > 0:
+ hasTranscriptGrowth = transcriptBlobSize > state.CheckpointTranscriptSize
+ case state.CheckpointTranscriptStart > 0:
+ // Legacy session: condensed at least once (has line count) but no size tracking.
+ // Cannot safely compare sizes — conservatively assume growth so condensation
+ // can do the full content check. After one condensation with the new CLI,
+ // CheckpointTranscriptSize will be populated and this path won't be hit again.
+ hasTranscriptGrowth = true
+ default:
+ // Never condensed (CheckpointTranscriptStart == 0): any content means growth.
+ hasTranscriptGrowth = transcriptBlobSize > 0
+ }
+ hasUncommittedFiles := len(state.FilesTouched) > 0
+
+ logging.Debug(
+ logCtx, "sessionHasNewContent: transcript size check",
+ slog.String("session_id", state.SessionID),
+ slog.Int64("transcript_blob_size", transcriptBlobSize),
+ slog.Int64("checkpoint_transcript_size", state.CheckpointTranscriptSize),
+ slog.Bool("has_transcript_growth", hasTranscriptGrowth),
+ slog.Bool("has_uncommitted_files", hasUncommittedFiles),
+ )
+
+ if !hasTranscriptGrowth && !hasUncommittedFiles {
+ return false, nil // No new content and no carry-forward files
+ }
+
+ // Check if staged files overlap with session's files with content-aware matching.
+ // This is primarily for PrepareCommitMsg; in PostCommit, stagedFiles is nil/empty.
+ if len(opts.stagedFiles) > 0 {
+ result := stagedFilesOverlapWithContent(ctx, repo, tree, opts.stagedFiles, state.FilesTouched)
+ logging.Debug(
+ logCtx, "sessionHasNewContent: staged files overlap check",
+ slog.String("session_id", state.SessionID),
+ slog.Int("staged_files", len(opts.stagedFiles)),
+ slog.Bool("result", result),
+ )
+ return result, nil
+ }
+
+ // No staged files - either PostCommit context or edge case.
+ //
+ // For recently-active IDLE sessions, the staged set is already gone by
+ // PostCommit, but carry-forward files from the just-ended turn must still
+ // count as "new content". The caller performs the stricter committed-file
+ // overlap check before actually condensing, which prevents false positives
+ // from unrelated commits.
+ //
+ // Stale IDLE sessions and ENDED sessions should NOT take this path. Those
+ // states may retain FilesTouched from older work, and treating that alone as
+ // fresh content would incorrectly condense old sessions into unrelated commits.
+ if hasUncommittedFiles && state.Phase == session.PhaseIdle && isRecentInteraction(state.LastInteractionTime) {
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no staged files, returning true due to recent idle carry-forward files",
+ slog.String("session_id", state.SessionID),
+ slog.Bool("has_transcript_growth", hasTranscriptGrowth),
+ slog.Bool("has_uncommitted_files", hasUncommittedFiles),
+ slog.String("phase", string(state.Phase)),
+ )
+ return true, nil
+ }
+
+ // No staged files and no carry-forward files: transcript growth is the only
+ // remaining signal that there is new session content to condense.
+ logging.Debug(
+ logCtx, "sessionHasNewContent: no staged files, returning transcript growth",
+ slog.String("session_id", state.SessionID),
+ slog.Bool("has_transcript_growth", hasTranscriptGrowth),
+ slog.Bool("has_uncommitted_files", hasUncommittedFiles),
+ )
+ return hasTranscriptGrowth, nil
+}
+
+// sessionHasNewContentFromLiveTranscript checks if a session has new content
+// by examining the live transcript file. This is used when no shadow branch exists
+// (i.e., no Stop has happened yet) but the agent may have done work.
+//
+// Returns true if:
+// 1. The transcript has grown since the last condensation, AND
+// 2. The new transcript portion contains file modifications, AND
+// 3. At least one modified file overlaps with the currently staged files
+//
+// The overlap check ensures we don't add checkpoint trailers to commits that are
+// unrelated to the agent's recent changes.
+//
+// stagedFiles is the pre-computed list of staged files from the caller.
+//
+// This handles the scenario where the agent commits mid-session before Stop.
+func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(ctx context.Context, state *SessionState, stagedFiles []string) (bool, error) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ if !s.hasNewTranscriptWork(ctx, state) {
+ return false, nil
+ }
+
+ // Prefer hook-populated files. If empty, extract from transcript directly —
+ // hasNewTranscriptWork already called PrepareTranscript, so we bypass
+ // resolveFilesTouched (which would prepare again) and extract directly.
+ modifiedFiles := state.FilesTouched
+ if len(modifiedFiles) == 0 {
+ modifiedFiles = s.extractModifiedFilesFromLiveTranscript(ctx, state, state.CheckpointTranscriptStart)
+ }
+ if len(modifiedFiles) == 0 {
+ return false, nil
+ }
+
+ logging.Debug(
+ logCtx, "live transcript check: found file modifications",
+ slog.String("session_id", state.SessionID),
+ slog.Int("modified_files", len(modifiedFiles)),
+ )
+
+ logging.Debug(
+ logCtx, "live transcript check: comparing staged vs modified",
+ slog.String("session_id", state.SessionID),
+ slog.Int("staged_files", len(stagedFiles)),
+ slog.Int("modified_files", len(modifiedFiles)),
+ )
+
+ if !hasOverlappingFiles(stagedFiles, modifiedFiles) {
+ logging.Debug(
+ logCtx, "live transcript check: no overlap between staged and modified files",
+ slog.String("session_id", state.SessionID),
+ )
+ return false, nil // No overlap - staged files are unrelated to agent's work
+ }
+
+ return true, nil
+}
+
+// resolveFilesTouched returns the file list for a session.
+// Prefers hook-populated state.FilesTouched, falls back to transcript extraction.
+// All call sites that need "what files did the agent touch?" should use this.
+//
+// Handles PrepareTranscript internally before falling back to extraction,
+// so callers don't need to prepare the transcript first.
+func (s *ManualCommitStrategy) resolveFilesTouched(ctx context.Context, state *SessionState) []string {
+ if len(state.FilesTouched) > 0 {
+ result := make([]string, len(state.FilesTouched))
+ copy(result, state.FilesTouched)
+ return result
+ }
+
+ // Prepare transcript before extraction (e.g., OpenCode `opencode export`).
+ prepareTranscriptForState(ctx, state)
+
+ return s.extractModifiedFilesFromLiveTranscript(ctx, state, state.CheckpointTranscriptStart)
+}
+
+// hasNewTranscriptWork checks if the agent has done work since the last condensation.
+// Uses agent-delegated GetTranscriptPosition() — does NOT do file extraction.
+// All call sites that need "has the agent done new work?" should use this.
+//
+// Returns false if: no transcript path, unknown agent type, agent doesn't implement
+// TranscriptAnalyzer, or GetTranscriptPosition fails. This is intentional fail-safe
+// behavior: callers treat false as "no new work detected", which conservatively
+// skips condensation on errors.
+func (s *ManualCommitStrategy) hasNewTranscriptWork(ctx context.Context, state *SessionState) bool {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ if state.TranscriptPath == "" || state.AgentType == "" {
+ return false
+ }
+
+ // Re-resolve transcript path — handles agents that relocate transcripts mid-session.
+ if _, resolveErr := resolveTranscriptPath(state); resolveErr != nil {
+ logging.Debug(
+ logCtx, "hasNewTranscriptWork: transcript path resolution failed",
+ slog.String("session_id", state.SessionID),
+ slog.Any("error", resolveErr),
+ )
+ return false
+ }
+
+ ag, err := agent.GetByAgentType(state.AgentType)
+ if err != nil {
+ return false
+ }
+
+ // Ensure transcript file is up-to-date (OpenCode creates/refreshes it via `opencode export`).
+ // Only wait for flush when the session is active — for idle/ended sessions the
+ // transcript is already fully flushed (the Stop hook completed the flush).
+ if state.Phase.IsActive() {
+ if preparer, ok := agent.AsTranscriptPreparer(ag); ok {
+ if prepErr := preparer.PrepareTranscript(ctx, state.TranscriptPath); prepErr != nil {
+ logging.Debug(
+ logCtx, "prepare transcript failed",
+ slog.String("session_id", state.SessionID),
+ slog.String("agent_type", string(state.AgentType)),
+ slog.String("transcript_path", state.TranscriptPath),
+ slog.Any("error", prepErr),
+ )
+ }
+ }
+ }
+ analyzer, ok := agent.AsTranscriptAnalyzer(ag)
+ if !ok {
+ return false
+ }
+
+ currentPos, err := analyzer.GetTranscriptPosition(state.TranscriptPath)
+ if err != nil {
+ logging.Debug(
+ logCtx, "hasNewTranscriptWork: GetTranscriptPosition failed",
+ slog.String("session_id", state.SessionID),
+ slog.String("transcript_path", state.TranscriptPath),
+ slog.Any("error", err),
+ )
+ return false
+ }
+
+ if currentPos <= state.CheckpointTranscriptStart {
+ logging.Debug(
+ logCtx, "hasNewTranscriptWork: no new content",
+ slog.String("session_id", state.SessionID),
+ slog.Int("current_pos", currentPos),
+ slog.Int("start_offset", state.CheckpointTranscriptStart),
+ )
+ return false
+ }
+
+ return true
+}
+
+// extractModifiedFilesFromLiveTranscript extracts modified files from the live transcript
+// (including subagent transcripts) starting from the given offset, and normalizes them
+// to repo-relative paths. Returns the normalized file list.
+//
+// Callers must ensure the transcript is prepared (e.g., via prepareTranscriptForState
+// or hasNewTranscriptWork) before calling this function.
+func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx context.Context, state *SessionState, offset int) []string {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ if state.TranscriptPath == "" || state.AgentType == "" {
+ return nil
+ }
+
+ // Re-resolve transcript path — handles agents that relocate transcripts mid-session.
+ if _, resolveErr := resolveTranscriptPath(state); resolveErr != nil {
+ logging.Debug(
+ logCtx, "extractModifiedFilesFromLiveTranscript: transcript path resolution failed",
+ slog.String("session_id", state.SessionID),
+ slog.Any("error", resolveErr),
+ )
+ return nil
+ }
+
+ ag, err := agent.GetByAgentType(state.AgentType)
+ if err != nil {
+ return nil
+ }
+
+ analyzer, ok := agent.AsTranscriptAnalyzer(ag)
+ if !ok {
+ return nil
+ }
+
+ var modifiedFiles []string
+
+ // Prefer SubagentAwareExtractor for agents that support it, to include
+ // subagent transcript files in a single pass. Fall back to basic extraction.
+ if subagentExtractor, subOk := agent.AsSubagentAwareExtractor(ag); subOk {
+ subagentsDir := filepath.Join(filepath.Dir(state.TranscriptPath), state.SessionID, "subagents")
+ transcriptData, readErr := os.ReadFile(state.TranscriptPath)
+ if readErr != nil {
+ logging.Debug(
+ logCtx, "extractModifiedFilesFromLiveTranscript: failed to read transcript",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", readErr.Error()),
+ )
+ } else {
+ allFiles, extractErr := subagentExtractor.ExtractAllModifiedFiles(transcriptData, offset, subagentsDir)
+ if extractErr != nil {
+ logging.Debug(
+ logCtx, "extractModifiedFilesFromLiveTranscript: extraction failed",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", extractErr.Error()),
+ )
+ } else {
+ modifiedFiles = allFiles
+ }
+ }
+ } else {
+ files, _, err := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, offset)
+ if err != nil {
+ logging.Debug(
+ logCtx, "extractModifiedFilesFromLiveTranscript: main transcript extraction failed",
+ slog.String("transcript_path", state.TranscriptPath),
+ slog.Any("error", err),
+ )
+ } else {
+ modifiedFiles = files
+ }
+ }
+
+ if len(modifiedFiles) == 0 {
+ return nil
+ }
+
+ // Normalize to repo-relative paths.
+ // Transcript tool_use entries contain absolute paths (e.g., /Users/alex/project/src/main.go)
+ // but getStagedFiles/committedFiles use repo-relative paths (e.g., src/main.go).
+ basePath := state.WorktreePath
+ if basePath == "" {
+ if wp, wpErr := paths.WorktreeRoot(ctx); wpErr == nil {
+ basePath = wp
+ }
+ }
+ if basePath != "" {
+ normalized := make([]string, 0, len(modifiedFiles))
+ for _, f := range modifiedFiles {
+ if rel := paths.ToRelativePath(f, basePath); rel != "" {
+ normalized = append(normalized, filepath.ToSlash(rel))
+ } else if len(f) > 0 && !filepath.IsAbs(f) && f[0] != '/' {
+ // Already relative — keep as-is
+ normalized = append(normalized, filepath.ToSlash(f))
+ }
+ // else: absolute path outside repo — skip. These can't match
+ // committed file paths (which are repo-relative) and would
+ // create phantom carry-forward branches.
+ }
+ modifiedFiles = normalized
+ }
+
+ return modifiedFiles
+}
+
+// warnIfAttributionDiverged prints at most one stderr warning per call and
+// marks every divergent session as notified so subsequent invocations stay
+// silent until the next successful condensation (or reconcile) realigns
+// attribution and clears the flag via State.RealignAttributionBase.
+//
+// Divergence arises when the migrate path advances BaseCommit to a new HEAD
+// but intentionally leaves AttributionBaseCommit pinned (e.g., after a pull
+// or git reset to an unrelated commit). Writing to stderrWriter surfaces the
+// message in the user's terminal during prepare-commit-msg, not the agent's
+// transcript — stderr from the hook is TTY-bound to the invoking process.
+func (s *ManualCommitStrategy) warnIfAttributionDiverged(ctx context.Context, sessions []*SessionState) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ printed := false
+ for _, sess := range sessions {
+ if sess.AttributionBaseCommit == "" ||
+ sess.AttributionBaseCommit == sess.BaseCommit ||
+ sess.DivergenceNoticeShown {
+ continue
+ }
+ if !printed {
+ fmt.Fprintln(stderrWriter, "trace: session attribution diverged after recent history movement; figures may be off until next checkpoint")
+ printed = true
+ }
+ sess.DivergenceNoticeShown = true
+ if err := s.saveSessionState(ctx, sess); err != nil {
+ logging.Warn(logCtx, "failed to save divergence notice flag",
+ slog.String("session_id", sess.SessionID),
+ slog.String("error", err.Error()))
+ }
+ }
+}
+
+// tryAgentCommitFastPath skips content detection for mid-turn agent commits.
+// Returns true if the fast path was taken (trailer added or attempt made),
+// false if the caller should continue with normal content detection.
+//
+// The fast path activates when an ACTIVE session exists and either:
+// - No TTY is available (agent subprocess, CI), or
+// - commit_linking="always" (user opted into auto-linking — needed because
+// some agents like Gemini subagents commit mid-turn from processes that
+// have /dev/tty but can't respond to prompts, and content detection fails
+// since the shadow branch doesn't exist yet).
+func (s *ManualCommitStrategy) tryAgentCommitFastPath(ctx context.Context, commitMsgFile string, sessions []*SessionState, source string) bool {
+ noTTY := !interactive.CanPromptInteractively()
+ skipContentDetection := noTTY
+ if !skipContentDetection {
+ if stngs, err := settings.Load(ctx); err == nil {
+ skipContentDetection = stngs.GetCommitLinking() == settings.CommitLinkingAlways
+ }
+ }
+ if !skipContentDetection {
+ return false
+ }
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ activeSessions := 0
+ emptyActiveSessions := 0
+ for _, state := range sessions {
+ if !state.Phase.IsActive() {
+ continue
+ }
+ activeSessions++
+ // Skip sessions that have no condensable content: no transcript path,
+ // no tracked files, and no shadow branch data (StepCount == 0). These
+ // would produce a Skipped result in CondenseSession, leaving the
+ // Trace-Checkpoint trailer pointing to nothing on the metadata branch.
+ // NOTE: conservative approximation of the skip gate in CondenseSession
+ // (which checks extracted data, not raw state). Keep aligned.
+ if state.TranscriptPath == "" && len(state.FilesTouched) == 0 && state.StepCount == 0 {
+ emptyActiveSessions++
+ logging.Debug(
+ logCtx, "prepare-commit-msg: fast path skipping empty session",
+ slog.String("session_id", state.SessionID),
+ slog.String("agent_type", string(state.AgentType)),
+ )
+ continue
+ }
+ _ = s.addTrailerForAgentCommit(logCtx, commitMsgFile, state, source) //nolint:errcheck // always returns nil; kept for signature stability
+ return true
+ }
+ // Log why fast path didn't fire — collect session phases for diagnostics.
+ phases := make([]string, 0, len(sessions))
+ for _, state := range sessions {
+ phases = append(phases, string(state.Phase))
+ }
+ message := "prepare-commit-msg: fast path found no ACTIVE sessions"
+ if activeSessions > 0 && emptyActiveSessions == activeSessions {
+ message = "prepare-commit-msg: fast path skipped all ACTIVE sessions as empty"
+ }
+ logging.Debug(
+ logCtx, message,
+ slog.Bool("no_tty", noTTY),
+ slog.Int("sessions", len(sessions)),
+ slog.Int("active_sessions", activeSessions),
+ slog.Int("empty_active_sessions", emptyActiveSessions),
+ slog.Any("session_phases", phases),
+ )
+ return false
+}
+
+// addTrailerForAgentCommit handles the fast path when an agent is committing
+// (ACTIVE session + no TTY). Generates a checkpoint ID and adds the trailer
+// directly, bypassing content detection and interactive prompts.
+func (s *ManualCommitStrategy) addTrailerForAgentCommit(logCtx context.Context, commitMsgFile string, state *SessionState, source string) error { //nolint:unparam // kept for signature stability
+ cpID, err := id.Generate()
+ if err != nil {
+ return nil //nolint:nilerr // Hook must be silent on failure
+ }
+
+ content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook
+ if err != nil {
+ return nil //nolint:nilerr // Hook must be silent on failure
+ }
+
+ message := string(content)
+
+ // Don't add if trailer already exists
+ if _, found := trailers.ParseCheckpoint(message); found {
+ return nil
+ }
+
+ message = addCheckpointTrailer(message, cpID)
+
+ logging.Info(
+ logCtx, "prepare-commit-msg: agent commit trailer added",
+ slog.String("strategy", "manual-commit"),
+ slog.String("source", source),
+ slog.String("checkpoint_id", cpID.String()),
+ slog.String("session_id", state.SessionID),
+ )
+
+ if err := os.WriteFile(commitMsgFile, []byte(message), 0o600); err != nil { //nolint:gosec // path from git hook arg
+ return nil //nolint:nilerr // Hook must be silent on failure
+ }
+ return nil
+}
+
+// addCheckpointTrailer adds the Trace-Checkpoint trailer to a commit message.
+// Delegates to trailers.AppendCheckpointTrailer for trailer-aware formatting.
+func addCheckpointTrailer(message string, checkpointID id.CheckpointID) string {
+ return trailers.AppendCheckpointTrailer(message, checkpointID.String())
+}
+
+// addCheckpointTrailerWithComment adds the Trace-Checkpoint trailer with an explanatory comment.
+// The trailer is placed above the git comment block but below the user's message area,
+// with a comment explaining that the user can remove it if they don't want to link the commit
+// to the agent session. If prompt is non-empty, it's shown as context.
+func addCheckpointTrailerWithComment(message string, checkpointID id.CheckpointID, agentName, prompt string) string {
+ trailer := trailers.CheckpointTrailerKey + ": " + checkpointID.String()
+ commentLines := []string{
+ "# Remove the Trace-Checkpoint trailer above if you don't want to link this commit to " + agentName + " session context.",
+ }
+ if prompt != "" {
+ commentLines = append(commentLines, "# Last Prompt: "+prompt)
+ }
+ commentLines = append(commentLines, "# The trailer will be added to your next commit based on this branch.")
+ comment := strings.Join(commentLines, "\n")
+
+ lines := strings.Split(message, "\n")
+
+ // Find where the git comment block starts (first # line)
+ commentStart := -1
+ for i, line := range lines {
+ if strings.HasPrefix(line, "#") {
+ commentStart = i
+ break
+ }
+ }
+
+ if commentStart == -1 {
+ // No git comments, append trailer at the end
+ return strings.TrimRight(message, "\n") + "\n\n" + trailer + "\n" + comment + "\n"
+ }
+
+ // Split into user content and git comments
+ userContent := strings.Join(lines[:commentStart], "\n")
+ gitComments := strings.Join(lines[commentStart:], "\n")
+
+ // Build result: user content, blank line, trailer, comment, blank line, git comments
+ userContent = strings.TrimRight(userContent, "\n")
+ if userContent == "" {
+ // No user content yet - leave space for them to type, then trailer
+ // Two newlines: first for user's message line, second for blank separator
+ return "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
+ }
+ return userContent + "\n\n" + trailer + "\n" + comment + "\n\n" + gitComments
+}
+
+// InitializeSession creates session state for a new session or updates an existing one.
+// This implements the optional SessionInitializer interface.
+// Called during UserPromptSubmit to allow git hooks to detect active sessions.
+//
+// If the session already exists and HEAD has moved (e.g., user committed), updates
+// BaseCommit to the new HEAD so future checkpoints go to the correct shadow branch.
+//
+// If there's an existing shadow branch with commits from a different session ID,
+// returns a SessionIDConflictError to prevent orphaning existing session work.
+//
+// agentType is the human-readable name of the agent (e.g., "Claude Code").
+// transcriptPath is the path to the live transcript file (for mid-session commit detection).
+// userPrompt is the user's prompt text (stored truncated as LastPrompt for display).
+// model is the LLM model identifier (e.g., "claude-sonnet-4-20250514"); empty if unknown.
+func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID string, agentType types.AgentType, transcriptPath string, userPrompt string, model string) error {
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to open git repository: %w", err)
+ }
+
+ // Resolve which agent actually owns this session.
+ resolvedAgentType := resolveSessionAgentType(ctx, sessionID, agentType, transcriptPath)
+
+ // Check if session already exists
+ state, err := s.loadSessionState(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("failed to check session state: %w", err)
+ }
+
+ if state != nil && state.BaseCommit != "" {
+ // Session is fully initialized — apply phase transition for TurnStart.
+ if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
+ logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",
+ slog.String("session_id", sessionID),
+ slog.String("error", transErr.Error()))
+ }
+
+ // Generate a new TurnID for each turn (correlates carry-forward checkpoints)
+ turnID, err := id.Generate()
+ if err != nil {
+ return fmt.Errorf("failed to generate turn ID: %w", err)
+ }
+ state.TurnID = turnID.String()
+
+ // Update AgentType when it isn't set yet, or when the transcript path
+ // proves we're a different agent than the one stored.
+ if state.AgentType == "" && resolvedAgentType != "" {
+ state.AgentType = resolvedAgentType
+ } else if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed {
+ logging.Info(logging.WithComponent(ctx, "hooks"), "corrected session agent type from transcript path",
+ slog.String("session_id", sessionID),
+ slog.String("from", string(state.AgentType)),
+ slog.String("to", string(corrected)),
+ slog.String("transcript_path", transcriptPath))
+ state.AgentType = corrected
+ }
+
+ // Update ModelName if provided (model can change between turns)
+ if model != "" {
+ state.ModelName = model
+ }
+
+ // Update LastPrompt on every turn so condensation always has the current prompt
+ if userPrompt != "" {
+ state.LastPrompt = truncatePromptForStorage(userPrompt)
+ }
+
+ // Update transcript path if provided (may change on session resume)
+ if transcriptPath != "" && state.TranscriptPath != transcriptPath {
+ state.TranscriptPath = transcriptPath
+ }
+
+ // ORDERING: attribution runs BEFORE migrate to use the pre-migration
+ // BaseCommit as the base tree (preserving correct agent-line counts when
+ // HEAD moved between turns via pull/rebase). Migrate runs BEFORE the
+ // LastCheckpointID clear so the reconcile guard can read the checkpoint ID.
+ //
+ // Sequence: attribution → migrate → clear
+ //
+ // 1. Attribution uses state.BaseCommit to locate the shadow branch and
+ // base tree. Running it before migrate ensures it diffs against the
+ // original base, not the post-migration HEAD.
+ // 2. Migrate/reconcile reads state.LastCheckpointID — clearing it first
+ // would prevent the reconcile path from ever firing at turn start.
+
+ // Calculate attribution at prompt start (BEFORE agent makes any changes)
+ // This captures user edits since the last checkpoint (or base commit for first prompt).
+ // IMPORTANT: Always calculate attribution, even for the first checkpoint, to capture
+ // user edits made before the first prompt. The inner CalculatePromptAttribution handles
+ // nil lastCheckpointTree by falling back to baseTree.
+ promptAttr := s.calculatePromptAttributionAtStart(ctx, repo, state)
+ state.PendingPromptAttribution = &promptAttr
+
+ // Check if HEAD has moved (user pulled/rebased or committed)
+ // migrateShadowBranchIfNeeded handles renaming the shadow branch and updating state.BaseCommit
+ _, reconciled, err := s.migrateShadowBranchIfNeeded(ctx, repo, state)
+ if err != nil {
+ return fmt.Errorf("failed to check/migrate shadow branch: %w", err)
+ }
+ if reconciled {
+ // Reconcile advanced BaseCommit + AttributionBaseCommit to HEAD (the
+ // known checkpoint we reset to). The attribution just computed is
+ // against the stale pre-reset base and would count discarded-history
+ // edits as churn. Recompute against the new base so the next
+ // checkpoint sees accurate user-delta.
+ recomputed := s.calculatePromptAttributionAtStart(ctx, repo, state)
+ state.PendingPromptAttribution = &recomputed
+ }
+
+ // Clear checkpoint IDs on every new prompt.
+ // LastCheckpointID is set during PostCommit, cleared at new prompt.
+ // TurnCheckpointIDs tracks mid-turn checkpoints for stop-time finalization.
+ state.LastCheckpointID = ""
+ state.TurnCheckpointIDs = nil
+
+ if err := s.saveSessionState(ctx, state); err != nil {
+ return fmt.Errorf("failed to update session state: %w", err)
+ }
+ return nil
+ }
+ // If state exists but BaseCommit is empty, it's a partial state from concurrent warning
+ // Continue below to properly initialize it
+
+ // Initialize new session
+ state, err = s.initializeSession(ctx, repo, sessionID, resolvedAgentType, transcriptPath, userPrompt, model)
+ if err != nil {
+ return fmt.Errorf("failed to initialize session: %w", err)
+ }
+
+ // Apply phase transition: new session starts as ACTIVE.
+ if transErr := TransitionAndLog(ctx, state, session.EventTurnStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
+ logging.Warn(logging.WithComponent(ctx, "hooks"), "turn start transition failed",
+ slog.String("session_id", sessionID),
+ slog.String("error", transErr.Error()))
+ }
+
+ // Calculate attribution for pre-prompt edits
+ // This captures any user edits made before the first prompt
+ promptAttr := s.calculatePromptAttributionAtStart(ctx, repo, state)
+ state.PendingPromptAttribution = &promptAttr
+ if err = s.saveSessionState(ctx, state); err != nil {
+ return fmt.Errorf("failed to save attribution: %w", err)
+ }
+
+ logging.Info(logging.WithComponent(ctx, "hooks"), "initialized shadow session",
+ slog.String("session_id", sessionID))
+ return nil
+}
diff --git a/cli/strategy/manual_commit_hooks_4.go b/cli/strategy/manual_commit_hooks_4.go
new file mode 100644
index 0000000..068c92f
--- /dev/null
+++ b/cli/strategy/manual_commit_hooks_4.go
@@ -0,0 +1,725 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/remote"
+ "github.com/GrayCodeAI/trace/cli/gitops"
+ "github.com/GrayCodeAI/trace/cli/logging"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/settings"
+ "github.com/GrayCodeAI/trace/perf"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/go-git/go-git/v6/utils/binary"
+)
+
+// calculatePromptAttributionAtStart calculates attribution at prompt start (before agent runs).
+// This captures user changes since the last checkpoint - no filtering needed since
+// the agent hasn't made any changes yet.
+//
+// IMPORTANT: This reads from the worktree (not staging area) to match what WriteTemporary
+// captures in checkpoints. If we read staged content but checkpoints capture worktree content,
+// unstaged changes would be in the checkpoint but not counted in PromptAttribution, causing
+// them to be incorrectly attributed to the agent later.
+func (s *ManualCommitStrategy) calculatePromptAttributionAtStart(
+ ctx context.Context,
+ repo *git.Repository,
+ state *SessionState,
+) PromptAttribution {
+ logCtx := logging.WithComponent(ctx, "attribution")
+ nextCheckpointNum := state.StepCount + 1
+ result := PromptAttribution{CheckpointNumber: nextCheckpointNum}
+
+ // Get last checkpoint tree from shadow branch (if it exists).
+ // For a new session (StepCount == 0), always use baseTree as the reference.
+ // The shadow branch may contain checkpoints from OTHER concurrent sessions,
+ // and using that tree would miss pre-session worktree dirt (e.g., .claude/settings.json)
+ // because it appears unchanged when compared to another session's snapshot.
+ var lastCheckpointTree *object.Tree
+ if state.StepCount > 0 {
+ // Existing session with prior checkpoints — use shadow branch as reference.
+ shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ if ref, err := repo.Reference(refName, true); err != nil {
+ logging.Debug(logCtx, "prompt attribution: no shadow branch",
+ slog.String("shadow_branch", shadowBranchName))
+ } else if shadowCommit, err := repo.CommitObject(ref.Hash()); err != nil {
+ logging.Debug(logCtx, "prompt attribution: failed to get shadow commit",
+ slog.String("shadow_ref", ref.Hash().String()),
+ slog.String("error", err.Error()))
+ } else if tree, err := shadowCommit.Tree(); err != nil {
+ logging.Debug(logCtx, "prompt attribution: failed to get shadow tree",
+ slog.String("error", err.Error()))
+ } else {
+ lastCheckpointTree = tree
+ }
+ }
+ // For new sessions (StepCount == 0), lastCheckpointTree stays nil.
+ // CalculatePromptAttribution falls back to baseTree, ensuring pre-session
+ // worktree dirt is captured even when the shadow branch has other sessions' data.
+
+ // Get base tree for agent lines calculation
+ var baseTree *object.Tree
+ if baseCommit, err := repo.CommitObject(plumbing.NewHash(state.BaseCommit)); err == nil {
+ if tree, treeErr := baseCommit.Tree(); treeErr == nil {
+ baseTree = tree
+ } else {
+ logging.Debug(logCtx, "prompt attribution: base tree unavailable",
+ slog.String("error", treeErr.Error()))
+ }
+ } else {
+ logging.Debug(logCtx, "prompt attribution: base commit unavailable",
+ slog.String("base_commit", state.BaseCommit),
+ slog.String("error", err.Error()))
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ logging.Debug(logCtx, "prompt attribution skipped: failed to get worktree",
+ slog.String("error", err.Error()))
+ return result
+ }
+
+ // Get worktree status to find ALL changed files
+ status, err := worktree.Status()
+ if err != nil {
+ logging.Debug(logCtx, "prompt attribution skipped: failed to get worktree status",
+ slog.String("error", err.Error()))
+ return result
+ }
+
+ worktreeRoot := worktree.Filesystem.Root()
+
+ // Build map of changed files with their worktree content
+ // IMPORTANT: We read from worktree (not staging area) to match what WriteTemporary
+ // captures in checkpoints. This ensures attribution is consistent.
+ changedFiles := make(map[string]string)
+ for filePath, fileStatus := range status {
+ // Skip unmodified files
+ if fileStatus.Worktree == git.Unmodified && fileStatus.Staging == git.Unmodified {
+ continue
+ }
+ // Skip .trace metadata directory (session data, not user code)
+ if strings.HasPrefix(filePath, paths.TraceMetadataDir+"/") || strings.HasPrefix(filePath, ".trace/") {
+ continue
+ }
+
+ // Always read from worktree to match checkpoint behavior
+ fullPath := filepath.Join(worktreeRoot, filePath)
+ var content string
+ if data, err := os.ReadFile(fullPath); err == nil { //nolint:gosec // filePath is from git worktree status
+ // Use git's binary detection algorithm (matches getFileContent behavior).
+ // Binary files are excluded from line-based attribution calculations.
+ isBinary, binErr := binary.IsBinary(bytes.NewReader(data))
+ if binErr == nil && !isBinary {
+ content = string(data)
+ }
+ }
+ // else: file deleted, unreadable, or binary - content remains empty string
+
+ changedFiles[filePath] = content
+ }
+
+ // Use CalculatePromptAttribution from manual_commit_attribution.go
+ result = CalculatePromptAttribution(baseTree, lastCheckpointTree, changedFiles, nextCheckpointNum)
+
+ return result
+}
+
+// getStagedFiles returns a list of files staged for commit using native git CLI.
+// This is much faster than go-git's worktree.Status() which scans the entire
+// working tree. `git diff --cached --name-only` uses native git's optimized index
+// and filesystem monitors.
+//
+// Returns (non-nil empty slice, nil) when no files are staged — callers can
+// distinguish "no staged files" from "error resolving staged files" (nil, err).
+func getStagedFiles(ctx context.Context) ([]string, error) {
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("resolve worktree root: %w", err)
+ }
+
+ cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--name-only")
+ cmd.Dir = repoRoot
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git diff --cached: %w", err)
+ }
+
+ staged := []string{}
+ trimmed := strings.TrimSpace(string(output))
+ // Normalize Windows line endings (\r\n) to Unix (\n) for cross-platform git output
+ trimmed = strings.ReplaceAll(trimmed, "\r\n", "\n")
+ for _, line := range strings.Split(trimmed, "\n") {
+ if line != "" {
+ staged = append(staged, filepath.ToSlash(line))
+ }
+ }
+ return staged, nil
+}
+
+// getLastPrompt retrieves the most recent user prompt from a session's shadow branch.
+// Reads prompt.txt directly from the shadow branch tree instead of parsing the full
+// transcript (which involves token counting, context generation, etc.).
+// Returns empty string if no prompt can be retrieved.
+func (s *ManualCommitStrategy) getLastPrompt(ctx context.Context, repo *git.Repository, state *SessionState) string {
+ prompts := readPromptsFromShadowBranch(ctx, repo, state)
+ if len(prompts) == 0 {
+ return ""
+ }
+ // Iterate backwards to find the last non-empty prompt.
+ for i := len(prompts) - 1; i >= 0; i-- {
+ cleaned := strings.TrimSpace(prompts[i])
+ if cleaned != "" && !isOnlySeparators(cleaned) {
+ return cleaned
+ }
+ }
+ return ""
+}
+
+// readPromptsFromShadowBranch reads prompt.txt from the shadow branch tree.
+// Returns all prompts split on "\n\n---\n\n", or nil if prompt.txt is not available.
+func readPromptsFromShadowBranch(_ context.Context, repo *git.Repository, state *SessionState) []string {
+ shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ refName := plumbing.NewBranchReferenceName(shadowBranchName)
+ ref, err := repo.Reference(refName, true)
+ if err != nil {
+ return nil
+ }
+
+ commit, err := repo.CommitObject(ref.Hash())
+ if err != nil {
+ return nil
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil
+ }
+
+ metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
+ promptPath := metadataDir + "/" + paths.PromptFileName
+ file, err := tree.File(promptPath)
+ if err != nil {
+ return nil
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ return nil
+ }
+
+ return splitPromptContent(content)
+}
+
+// HandleTurnEnd dispatches strategy-specific actions emitted when an agent turn ends.
+// The primary job is to finalize all checkpoints from this turn with the full transcript.
+//
+// During a turn, PostCommit writes provisional transcript data (whatever was available
+// at commit time). HandleTurnEnd replaces that with the complete session transcript
+// (from prompt to stop event), ensuring every checkpoint has the full context.
+//
+
+func (s *ManualCommitStrategy) HandleTurnEnd(ctx context.Context, state *SessionState) error { //nolint:unparam // error return is part of the hook contract; callers check it
+ hadMidTurnCommits := len(state.TurnCheckpointIDs) > 0
+
+ // Finalize all checkpoints from this turn with the full transcript.
+ //
+ // IMPORTANT: This is best-effort - errors are logged but don't fail the hook.
+ // Failing here would prevent session cleanup and could leave state inconsistent.
+ // The provisional transcript from PostCommit is already persisted, so the
+ // checkpoint isn't lost - it just won't have the complete transcript.
+ errCount := s.finalizeAllTurnCheckpoints(ctx, state)
+ if errCount > 0 {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ logging.Warn(
+ logCtx, "HandleTurnEnd completed with errors (best-effort)",
+ slog.String("session_id", state.SessionID),
+ slog.Int("error_count", errCount),
+ )
+ }
+
+ // Advance CheckpointTranscriptStart to the actual transcript end after
+ // mid-turn commits. When an agent commits mid-turn (e.g., Codex "commit/push"),
+ // condensation records TotalTranscriptLines at commit time, but the agent
+ // continues writing to the transcript (tool results, token counts, task_complete).
+ // Without this fix, the next checkpoint's scoped transcript starts mid-turn,
+ // including a tail of already-condensed content.
+ //
+ // Skip this when carry-forward is active. carryForwardToNewShadowBranch
+ // intentionally resets CheckpointTranscriptStart to 0 so the next checkpoint
+ // remains self-contained with the full transcript.
+ if hadMidTurnCommits && state.TranscriptPath != "" && len(state.FilesTouched) == 0 {
+ transcriptPath, resolveErr := resolveTranscriptPath(state)
+ if resolveErr == nil {
+ if ag, agErr := agent.GetByAgentType(state.AgentType); agErr == nil {
+ if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok {
+ if pos, posErr := analyzer.GetTranscriptPosition(transcriptPath); posErr == nil && pos > state.CheckpointTranscriptStart {
+ logging.Debug(
+ logging.WithComponent(ctx, "hooks"),
+ "advancing CheckpointTranscriptStart to turn end after mid-turn commit",
+ slog.String("session_id", state.SessionID),
+ slog.Int("old_offset", state.CheckpointTranscriptStart),
+ slog.Int("new_offset", pos),
+ )
+ state.CheckpointTranscriptStart = pos
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// precomputeTranscriptBlobsForFinalize chunks + zlib-compresses the redacted
+// transcript once for reuse across every checkpoint in the turn. Returns nil
+// (without error) when the transcript is empty — downstream stores skip
+// transcript updates in that case, so precompute would only write a wasted
+// empty-chunk blob to the object store. On failure, logs a warning and
+// returns nil so the loop falls back to per-checkpoint chunking.
+func precomputeTranscriptBlobsForFinalize(ctx context.Context, repo *git.Repository, transcript redact.RedactedBytes, state *SessionState) *checkpoint.PrecomputedTranscriptBlobs {
+ if transcript.Len() == 0 {
+ return nil
+ }
+ _, span := perf.Start(ctx, "precompute_transcript_blobs")
+ defer span.End()
+ precomputed, err := checkpoint.PrecomputeTranscriptBlobs(ctx, repo, transcript, state.AgentType)
+ if err != nil {
+ logging.Warn(
+ ctx, "finalize: precompute transcript blobs failed, falling back to per-checkpoint work",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()),
+ )
+ return nil
+ }
+ return precomputed
+}
+
+// finalizeAllTurnCheckpoints replaces the provisional transcript in each checkpoint
+// created during this turn with the full session transcript.
+//
+// This is called at turn end (stop hook). During the turn, PostCommit wrote whatever
+// transcript was available at commit time. Now we have the complete transcript and
+// replace it so every checkpoint has the full prompt-to-stop context.
+//
+// Returns the number of errors encountered (best-effort: continues processing on error).
+func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, state *SessionState) int {
+ if len(state.TurnCheckpointIDs) == 0 {
+ return 0 // No mid-turn commits to finalize
+ }
+
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+
+ logging.Info(
+ logCtx, "finalizing turn checkpoints with full transcript",
+ slog.String("session_id", state.SessionID),
+ slog.Int("checkpoint_count", len(state.TurnCheckpointIDs)),
+ )
+
+ errCount := 0
+
+ // Read full transcript from live transcript file, re-resolving the path if the
+ // agent relocated it mid-session (e.g., Cursor CLI flat → nested layout change).
+ if state.TranscriptPath == "" {
+ logging.Warn(
+ logCtx, "finalize: no transcript path, skipping",
+ slog.String("session_id", state.SessionID),
+ )
+ state.TurnCheckpointIDs = nil
+ return 1 // Count as error - all checkpoints will be skipped
+ }
+
+ transcriptPath, resolveErr := resolveTranscriptPath(state)
+ if resolveErr != nil {
+ logging.Warn(
+ logCtx, "finalize: transcript path resolution failed, skipping",
+ slog.String("session_id", state.SessionID),
+ slog.Any("error", resolveErr),
+ )
+ state.TurnCheckpointIDs = nil
+ return 1 // Count as error - all checkpoints will be skipped
+ }
+
+ fullTranscript, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath
+ if err != nil || len(fullTranscript) == 0 {
+ msg := "finalize: empty transcript, skipping"
+ if err != nil {
+ msg = "finalize: failed to read transcript, skipping"
+ }
+ logging.Warn(
+ logCtx, msg,
+ slog.String("session_id", state.SessionID),
+ slog.String("transcript_path", state.TranscriptPath),
+ slog.Any("error", err),
+ )
+ state.TurnCheckpointIDs = nil
+ return 1 // Count as error - all checkpoints will be skipped
+ }
+
+ // Open repository (needed for shadow branch prompt reading and checkpoint store)
+ repo, err := OpenRepository(ctx)
+ if err != nil {
+ logging.Warn(
+ logCtx, "finalize: failed to open repository",
+ slog.String("error", err.Error()),
+ )
+ state.TurnCheckpointIDs = nil
+ return 1 // Count as error - all checkpoints will be skipped
+ }
+
+ prompts := readPromptsFromShadowBranch(ctx, repo, state)
+ if len(prompts) == 0 {
+ prompts = readPromptsFromFilesystem(ctx, state.SessionID)
+ }
+
+ // Redact secrets before writing. Checkpoint store methods require
+ // pre-redacted in-memory transcript content from callers. The live
+ // transcript on disk is still treated as raw/untrusted input, so redact it
+ // here before anything is persisted to the metadata branch.
+ //
+ // On failure: drop the transcript but continue writing checkpoint metadata
+ // (attribution, files touched, prompts). Hooks run without user interaction
+ // so there is no retry path — preserving partial metadata is better than
+ // losing everything. Persisting an unredacted transcript would be worse.
+ _, redactSpan := perf.Start(logCtx, "redact_transcript")
+ redactedTranscript, redactErr := redact.JSONLBytes(fullTranscript)
+ redactSpan.End()
+ if redactErr != nil {
+ logging.Warn(
+ logCtx, "finalize: transcript redaction failed, dropping transcript",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", redactErr.Error()),
+ )
+ redactedTranscript = redact.RedactedBytes{}
+ }
+ for i, p := range prompts {
+ prompts[i] = redact.String(p)
+ }
+
+ store := checkpoint.NewGitStore(repo)
+ v2 := settings.CheckpointsVersion(logCtx) == 2
+
+ // Evaluate v2 flag once before the loop to avoid re-reading settings per checkpoint
+ var v2Store *checkpoint.V2GitStore
+ if settings.IsCheckpointsV2Enabled(logCtx) {
+ v2URL, err := remote.FetchURL(logCtx)
+ if err != nil {
+ logging.Debug(
+ logCtx, "finalize: using origin for v2 store fetch remote",
+ slog.String("error", err.Error()),
+ )
+ v2URL = originRemote
+ }
+ v2Store = checkpoint.NewV2GitStore(repo, v2URL)
+ }
+
+ precomputed := precomputeTranscriptBlobsForFinalize(logCtx, repo, redactedTranscript, state)
+
+ // Resolve the agent and try external compaction once before the loop —
+ // external compaction is invariant across checkpoints (same session/transcript).
+ // Internal compaction must remain per-checkpoint due to per-checkpoint startLine.
+ finalAg, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil; compactTranscriptForV2 handles nil
+ var externalCompact []byte
+ var isExternalAgent bool
+ if v2Store != nil {
+ externalCompact, isExternalAgent = compactAndRedactExternalTranscript(logCtx, finalAg, state)
+ }
+
+ // Update each checkpoint with the full transcript
+ for _, cpIDStr := range state.TurnCheckpointIDs {
+ cpID, parseErr := id.NewCheckpointID(cpIDStr)
+ if parseErr != nil {
+ logging.Warn(
+ logCtx, "finalize: invalid checkpoint ID, skipping",
+ slog.String("checkpoint_id", cpIDStr),
+ slog.String("error", parseErr.Error()),
+ )
+ errCount++
+ continue
+ }
+
+ updateOpts := checkpoint.UpdateCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: state.SessionID,
+ Transcript: redactedTranscript,
+ Prompts: prompts,
+ Agent: state.AgentType,
+ PrecomputedBlobs: precomputed,
+ }
+
+ // Generate compact transcript for v2 /main
+ if v2Store != nil {
+ if isExternalAgent {
+ updateOpts.CompactTranscript = externalCompact
+ } else if redactedTranscript.Len() > 0 {
+ updateOpts.CompactTranscript = finalizeInternalCompactTranscript(logCtx, finalAg, cpID, state, redactedTranscript, store, v2Store, v2)
+ }
+ }
+
+ if !v2 {
+ updateErr := store.UpdateCommitted(ctx, updateOpts)
+ if updateErr != nil {
+ logging.Warn(
+ logCtx, "finalize: failed to update checkpoint",
+ slog.String("checkpoint_id", cpIDStr),
+ slog.String("error", updateErr.Error()),
+ )
+ errCount++
+ continue
+ }
+ }
+
+ if v2Store != nil {
+ if v2Err := v2Store.UpdateCommitted(logCtx, updateOpts); v2Err != nil {
+ attrs := []any{
+ slog.String("checkpoint_id", cpIDStr),
+ slog.String("error", v2Err.Error()),
+ }
+ if v2 {
+ logging.Warn(logCtx, "finalize: failed to update checkpoint in v2", attrs...)
+ errCount++
+ continue
+ }
+ logging.Warn(logCtx, "v2 dual-write update failed", attrs...)
+ }
+ }
+
+ logging.Info(
+ logCtx, "finalize: checkpoint updated with full transcript",
+ slog.String("checkpoint_id", cpIDStr),
+ slog.String("session_id", state.SessionID),
+ )
+ }
+
+ // Clear turn checkpoint IDs. Do NOT update CheckpointTranscriptStart here — it was
+ // already set correctly by PostCommit: condenseAndUpdateState sets it to the total
+ // transcript lines when condensing, and carryForwardToNewShadowBranch resets it to 0
+ // when carry-forward is active. Overwriting here would break carry-forward by making
+ // sessionHasNewContent think the transcript is fully consumed (no growth).
+ state.TurnCheckpointIDs = nil
+
+ return errCount
+}
+
+// finalizeInternalCompactTranscript resolves the per-checkpoint startLine and
+// produces the compact transcript for built-in agents during finalization.
+func finalizeInternalCompactTranscript(
+ ctx context.Context,
+ ag agent.Agent,
+ cpID id.CheckpointID,
+ state *SessionState,
+ redactedTranscript redact.RedactedBytes,
+ store *checkpoint.GitStore,
+ v2Store *checkpoint.V2GitStore,
+ v2 bool,
+) []byte {
+ var (
+ content *checkpoint.SessionContent
+ readErr error
+ )
+ if v2 {
+ content, readErr = v2Store.ReadSessionContentByID(ctx, cpID, state.SessionID)
+ } else {
+ content, readErr = store.ReadSessionContentByID(ctx, cpID, state.SessionID)
+ }
+ startLine := 0
+ if readErr == nil && content != nil {
+ startLine = content.Metadata.GetTranscriptStart()
+ } else {
+ errMsg := "unknown"
+ if readErr != nil {
+ errMsg = readErr.Error()
+ }
+ logging.Debug(
+ ctx, "finalize: failed to read checkpoint metadata, using full transcript for compact output",
+ slog.String("checkpoint_id", cpID.String()),
+ slog.String("session_id", state.SessionID),
+ slog.String("error", errMsg),
+ )
+ }
+ return compactTranscriptForV2(ctx, ag, redactedTranscript, startLine)
+}
+
+// filesChangedInCommit returns the set of files changed in a commit using git diff-tree.
+// Uses the git CLI for faster performance vs go-git tree walks (lower constant factors).
+// Falls back to go-git tree walk if git diff-tree fails, since an empty result would
+// break downstream condensation and carry-forward logic.
+func filesChangedInCommit(ctx context.Context, repoDir string, commit *object.Commit, headTree, parentTree *object.Tree) map[string]struct{} {
+ var parentHash string
+ if commit.NumParents() > 0 {
+ parentHash = commit.ParentHashes[0].String()
+ }
+ result, err := gitops.DiffTreeFiles(ctx, repoDir, parentHash, commit.Hash.String())
+ if err != nil {
+ logging.Warn(
+ ctx, "post-commit: git diff-tree failed, falling back to tree walk",
+ slog.String("commit", commit.Hash.String()),
+ slog.String("error", err.Error()),
+ )
+ return filesChangedInCommitFallback(ctx, headTree, parentTree)
+ }
+ return result
+}
+
+// filesChangedInCommitFallback uses go-git tree walks to compute changed files.
+// Slower than git diff-tree but doesn't depend on an external process.
+func filesChangedInCommitFallback(ctx context.Context, headTree, parentTree *object.Tree) map[string]struct{} {
+ files, err := getAllChangedFilesBetweenTreesSlow(ctx, parentTree, headTree)
+ if err != nil {
+ logging.Warn(
+ ctx, "post-commit: tree walk fallback also failed; condensation and carry-forward may be affected",
+ slog.String("error", err.Error()),
+ )
+ return make(map[string]struct{})
+ }
+ result := make(map[string]struct{}, len(files))
+ for _, f := range files {
+ result[f] = struct{}{}
+ }
+ return result
+}
+
+// subtractFiles returns files that are NOT in the exclude set.
+func subtractFiles(files []string, exclude map[string]struct{}) []string {
+ var remaining []string
+ for _, f := range files {
+ if _, excluded := exclude[f]; !excluded {
+ remaining = append(remaining, f)
+ }
+ }
+ return remaining
+}
+
+// carryForwardToNewShadowBranch creates a new shadow branch at the current HEAD
+// containing the remaining uncommitted files and all session metadata.
+// This enables the next commit to get its own unique checkpoint.
+func (s *ManualCommitStrategy) carryForwardToNewShadowBranch(
+ ctx context.Context,
+ repo *git.Repository,
+ state *SessionState,
+ remainingFiles []string,
+) {
+ logCtx := logging.WithComponent(ctx, "checkpoint")
+ start := time.Now()
+ store := checkpoint.NewGitStore(repo)
+
+ // Don't include metadata directory in carry-forward. The carry-forward branch
+ // only needs to preserve file content for comparison - not the transcript.
+ // Including the transcript would cause sessionHasNewContent to always return true
+ // because CheckpointTranscriptStart is reset to 0 for carry-forward.
+ writeCtx, carryForwardWriteSpan := perf.Start(ctx, "write_carry_forward_shadow")
+ result, err := store.WriteTemporary(writeCtx, checkpoint.WriteTemporaryOptions{
+ SessionID: state.SessionID,
+ BaseCommit: state.BaseCommit,
+ WorktreeID: state.WorktreeID,
+ ModifiedFiles: remainingFiles,
+ MetadataDir: "",
+ MetadataDirAbs: "",
+ CommitMessage: "carry forward: uncommitted session files",
+ IsFirstCheckpoint: false,
+ })
+ if err != nil {
+ carryForwardWriteSpan.RecordError(err)
+ carryForwardWriteSpan.End()
+ logging.Warn(
+ logCtx, "post-commit: carry-forward failed",
+ slog.String("session_id", state.SessionID),
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+ carryForwardWriteSpan.End()
+ duration := time.Since(start)
+ if result.Skipped {
+ logging.Debug(
+ logCtx, "post-commit: carry-forward skipped (no changes)",
+ slog.String("session_id", state.SessionID),
+ )
+ return
+ }
+
+ // Update state for the carry-forward checkpoint.
+ // CheckpointTranscriptStart = 0 is intentional: each checkpoint is self-contained with
+ // the full transcript. This trades storage efficiency for simplicity:
+ // - Pro: Each checkpoint is independently readable without needing to stitch together
+ // multiple checkpoints to understand the session history
+ // - Con: For long sessions with multiple partial commits, each checkpoint includes
+ // the full transcript, which could be large
+ // An alternative would be incremental checkpoints (only new content since last condensation),
+ // but this would complicate checkpoint retrieval and require careful tracking of dependencies.
+ state.StepCount = 1
+ state.CheckpointTranscriptStart = 0
+ state.CompactTranscriptStart = 0
+ state.CheckpointTranscriptSize = 0
+ state.LastCheckpointID = ""
+ // NOTE: TurnCheckpointIDs is intentionally NOT cleared here. Those checkpoint
+ // IDs from earlier in the turn still need finalization with the full transcript
+ // when HandleTurnEnd runs at stop time.
+
+ logging.Info(
+ logCtx, "post-commit: carried forward remaining files",
+ slog.String("session_id", state.SessionID),
+ slog.Int("remaining_files", len(remainingFiles)),
+ )
+ logging.Debug(
+ logCtx, "carry-forward timings",
+ slog.String("session_id", state.SessionID),
+ slog.Int64("write_carry_forward_shadow_ms", duration.Milliseconds()),
+ slog.Int("remaining_files", len(remainingFiles)),
+ )
+}
+
+// resolveSessionAgentType picks the most reliable identifier for which agent
+// owns a session, given the agent whose hook is firing right now (callerAgentType),
+// an optional transcript path, and the SessionStart hint stored under the
+// session ID.
+func resolveSessionAgentType(ctx context.Context, sessionID string, callerAgentType types.AgentType, transcriptPath string) types.AgentType {
+ if repoRoot, err := paths.WorktreeRoot(ctx); err == nil && transcriptPath != "" {
+ if owner, ok := agent.ForTranscriptPath(transcriptPath, repoRoot); ok {
+ return owner.Type()
+ }
+ }
+ if hint := LoadAgentTypeHint(ctx, sessionID); hint != "" && hint != agent.AgentTypeUnknown {
+ return hint
+ }
+ return callerAgentType
+}
+
+// correctSessionAgentType returns the agent type that owns transcriptPath when
+// it disagrees with currentType. Returns (currentType, false) if there's no
+// disagreement or no transcript signal.
+func correctSessionAgentType(ctx context.Context, currentType types.AgentType, transcriptPath string) (types.AgentType, bool) {
+ if transcriptPath == "" {
+ return currentType, false
+ }
+ repoRoot, err := paths.WorktreeRoot(ctx)
+ if err != nil {
+ return currentType, false
+ }
+ owner, ok := agent.ForTranscriptPath(transcriptPath, repoRoot)
+ if !ok {
+ return currentType, false
+ }
+ if owner.Type() == currentType {
+ return currentType, false
+ }
+ return owner.Type(), true
+}
diff --git a/cli/strategy/manual_commit_rewind.go b/cli/strategy/manual_commit_rewind.go
index 7c25fb3..ac3b313 100644
--- a/cli/strategy/manual_commit_rewind.go
+++ b/cli/strategy/manual_commit_rewind.go
@@ -10,20 +10,17 @@ import (
"path/filepath"
"sort"
"strings"
- "time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/types"
cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/interactive"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/osroot"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/settings"
"github.com/GrayCodeAI/trace/cli/trailers"
- "charm.land/huh/v2"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/filemode"
@@ -815,203 +812,3 @@ func ResolveAgentForRewind(agentType types.AgentType) (agent.Agent, error) {
}
return ag, nil
}
-
-// readSessionPrompt reads the first prompt from the session's prompt.txt file stored in git.
-// Returns an empty string if the prompt cannot be read.
-func readSessionPrompt(repo *git.Repository, commitHash plumbing.Hash, metadataDir string) string {
- // Get the commit and its tree
- commit, err := repo.CommitObject(commitHash)
- if err != nil {
- return ""
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return ""
- }
-
- // Look for prompt.txt in the metadata directory
- promptPath := metadataDir + "/" + paths.PromptFileName
- promptEntry, err := tree.File(promptPath)
- if err != nil {
- return ""
- }
-
- content, err := promptEntry.Contents()
- if err != nil {
- return ""
- }
-
- return ExtractFirstPrompt(content)
-}
-
-// SessionRestoreStatus represents the status of a session being restored.
-type SessionRestoreStatus int
-
-const (
- StatusNew SessionRestoreStatus = iota // Local file doesn't exist
- StatusUnchanged // Local and checkpoint are the same
- StatusCheckpointNewer // Checkpoint has newer entries
- StatusLocalNewer // Local has newer entries (conflict)
-)
-
-// SessionRestoreInfo contains information about a session being restored.
-type SessionRestoreInfo struct {
- SessionID string
- Prompt string // First prompt preview for display
- Status SessionRestoreStatus // Status of this session
- LocalTime time.Time
- CheckpointTime time.Time
-}
-
-// classifySessionsForRestore checks all sessions in a checkpoint and returns info
-// about each session, including whether local logs have newer timestamps.
-// repoRoot is used to compute per-session agent directories.
-// Sessions without agent metadata are skipped (cannot determine target directory).
-func (s *ManualCommitStrategy) classifySessionsForRestore(ctx context.Context, repoRoot string, store committedReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo {
- var sessions []SessionRestoreInfo
-
- totalSessions := len(summary.Sessions)
- // Check all sessions (0-based indexing)
- for i := range totalSessions {
- content, err := store.ReadSessionContent(ctx, checkpointID, i)
- if err != nil || content == nil || len(content.Transcript) == 0 {
- continue
- }
-
- sessionID := content.Metadata.SessionID
- if sessionID == "" || content.Metadata.Agent == "" {
- continue
- }
-
- sessionAgent, agErr := ResolveAgentForRewind(content.Metadata.Agent)
- if agErr != nil {
- continue
- }
-
- // Compute transcript path from current repo location for cross-machine portability.
- sessionAgentDir, dirErr := sessionAgent.GetSessionDir(repoRoot)
- if dirErr != nil {
- continue
- }
- localPath := sessionAgent.ResolveSessionFile(sessionAgentDir, sessionID)
-
- localTime := paths.GetLastTimestampFromFile(localPath)
- checkpointTime := paths.GetLastTimestampFromBytes(content.Transcript)
- status := ClassifyTimestamps(localTime, checkpointTime)
-
- sessions = append(sessions, SessionRestoreInfo{
- SessionID: sessionID,
- Prompt: ExtractFirstPrompt(content.Prompts),
- Status: status,
- LocalTime: localTime,
- CheckpointTime: checkpointTime,
- })
- }
-
- return sessions
-}
-
-// ClassifyTimestamps determines the restore status based on local and checkpoint timestamps.
-func ClassifyTimestamps(localTime, checkpointTime time.Time) SessionRestoreStatus {
- // Local file doesn't exist (no timestamp found)
- if localTime.IsZero() {
- return StatusNew
- }
-
- // Can't determine checkpoint time - treat as new/safe
- if checkpointTime.IsZero() {
- return StatusNew
- }
-
- // Compare timestamps
- if localTime.After(checkpointTime) {
- return StatusLocalNewer
- }
- if checkpointTime.After(localTime) {
- return StatusCheckpointNewer
- }
- return StatusUnchanged
-}
-
-// StatusToText returns a human-readable status string.
-func StatusToText(status SessionRestoreStatus) string {
- switch status {
- case StatusNew:
- return "(new)"
- case StatusUnchanged:
- return "(unchanged)"
- case StatusCheckpointNewer:
- return "(checkpoint is newer)"
- case StatusLocalNewer:
- return "(local is newer)" // shouldn't appear in non-conflict list
- default:
- return ""
- }
-}
-
-// PromptOverwriteNewerLogs asks the user for confirmation to overwrite local
-// session logs that have newer timestamps than the checkpoint versions.
-func PromptOverwriteNewerLogs(errW io.Writer, sessions []SessionRestoreInfo) (bool, error) {
- if !interactive.CanPromptInteractively() {
- return false, errors.New("cannot prompt to overwrite local session logs in non-interactive mode; rerun with --force to overwrite or use a TTY to confirm")
- }
-
- // Separate conflicting and non-conflicting sessions
- var conflicting, nonConflicting []SessionRestoreInfo
- for _, s := range sessions {
- if s.Status == StatusLocalNewer {
- conflicting = append(conflicting, s)
- } else {
- nonConflicting = append(nonConflicting, s)
- }
- }
-
- fmt.Fprintf(errW, "\nWarning: Local session log(s) have newer entries than the checkpoint:\n")
- for _, info := range conflicting {
- // Show prompt if available, otherwise fall back to session ID
- if info.Prompt != "" {
- fmt.Fprintf(errW, " \"%s\"\n", info.Prompt)
- } else {
- fmt.Fprintf(errW, " Session: %s\n", info.SessionID)
- }
- fmt.Fprintf(errW, " Local last entry: %s\n", info.LocalTime.Local().Format("2006-01-02 15:04:05"))
- fmt.Fprintf(errW, " Checkpoint last entry: %s\n", info.CheckpointTime.Local().Format("2006-01-02 15:04:05"))
- }
-
- // Show non-conflicting sessions with their status
- if len(nonConflicting) > 0 {
- fmt.Fprintf(errW, "\nThese other session(s) will also be restored:\n")
- for _, info := range nonConflicting {
- statusText := StatusToText(info.Status)
- if info.Prompt != "" {
- fmt.Fprintf(errW, " \"%s\" %s\n", info.Prompt, statusText)
- } else {
- fmt.Fprintf(errW, " Session: %s %s\n", info.SessionID, statusText)
- }
- }
- }
-
- fmt.Fprintf(errW, "\nOverwriting will lose the newer local entries.\n\n")
-
- var confirmed bool
- form := huh.NewForm(
- huh.NewGroup(
- huh.NewConfirm().
- Title("Overwrite local session logs with checkpoint versions?").
- Value(&confirmed),
- ),
- )
- if isAccessibleMode() {
- form = form.WithAccessible(true)
- }
-
- if err := form.Run(); err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- return false, nil
- }
- return false, fmt.Errorf("failed to get confirmation: %w", err)
- }
-
- return confirmed, nil
-}
diff --git a/cli/strategy/manual_commit_rewind_2.go b/cli/strategy/manual_commit_rewind_2.go
new file mode 100644
index 0000000..d3b4cb0
--- /dev/null
+++ b/cli/strategy/manual_commit_rewind_2.go
@@ -0,0 +1,218 @@
+package strategy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ cpkg "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/interactive"
+ "github.com/GrayCodeAI/trace/cli/paths"
+
+ "charm.land/huh/v2"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+// readSessionPrompt reads the first prompt from the session's prompt.txt file stored in git.
+// Returns an empty string if the prompt cannot be read.
+func readSessionPrompt(repo *git.Repository, commitHash plumbing.Hash, metadataDir string) string {
+ // Get the commit and its tree
+ commit, err := repo.CommitObject(commitHash)
+ if err != nil {
+ return ""
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return ""
+ }
+
+ // Look for prompt.txt in the metadata directory
+ promptPath := metadataDir + "/" + paths.PromptFileName
+ promptEntry, err := tree.File(promptPath)
+ if err != nil {
+ return ""
+ }
+
+ content, err := promptEntry.Contents()
+ if err != nil {
+ return ""
+ }
+
+ return ExtractFirstPrompt(content)
+}
+
+// SessionRestoreStatus represents the status of a session being restored.
+type SessionRestoreStatus int
+
+const (
+ StatusNew SessionRestoreStatus = iota // Local file doesn't exist
+ StatusUnchanged // Local and checkpoint are the same
+ StatusCheckpointNewer // Checkpoint has newer entries
+ StatusLocalNewer // Local has newer entries (conflict)
+)
+
+// SessionRestoreInfo contains information about a session being restored.
+type SessionRestoreInfo struct {
+ SessionID string
+ Prompt string // First prompt preview for display
+ Status SessionRestoreStatus // Status of this session
+ LocalTime time.Time
+ CheckpointTime time.Time
+}
+
+// classifySessionsForRestore checks all sessions in a checkpoint and returns info
+// about each session, including whether local logs have newer timestamps.
+// repoRoot is used to compute per-session agent directories.
+// Sessions without agent metadata are skipped (cannot determine target directory).
+func (s *ManualCommitStrategy) classifySessionsForRestore(ctx context.Context, repoRoot string, store committedReader, checkpointID id.CheckpointID, summary *cpkg.CheckpointSummary) []SessionRestoreInfo {
+ var sessions []SessionRestoreInfo
+
+ totalSessions := len(summary.Sessions)
+ // Check all sessions (0-based indexing)
+ for i := range totalSessions {
+ content, err := store.ReadSessionContent(ctx, checkpointID, i)
+ if err != nil || content == nil || len(content.Transcript) == 0 {
+ continue
+ }
+
+ sessionID := content.Metadata.SessionID
+ if sessionID == "" || content.Metadata.Agent == "" {
+ continue
+ }
+
+ sessionAgent, agErr := ResolveAgentForRewind(content.Metadata.Agent)
+ if agErr != nil {
+ continue
+ }
+
+ // Compute transcript path from current repo location for cross-machine portability.
+ sessionAgentDir, dirErr := sessionAgent.GetSessionDir(repoRoot)
+ if dirErr != nil {
+ continue
+ }
+ localPath := sessionAgent.ResolveSessionFile(sessionAgentDir, sessionID)
+
+ localTime := paths.GetLastTimestampFromFile(localPath)
+ checkpointTime := paths.GetLastTimestampFromBytes(content.Transcript)
+ status := ClassifyTimestamps(localTime, checkpointTime)
+
+ sessions = append(sessions, SessionRestoreInfo{
+ SessionID: sessionID,
+ Prompt: ExtractFirstPrompt(content.Prompts),
+ Status: status,
+ LocalTime: localTime,
+ CheckpointTime: checkpointTime,
+ })
+ }
+
+ return sessions
+}
+
+// ClassifyTimestamps determines the restore status based on local and checkpoint timestamps.
+func ClassifyTimestamps(localTime, checkpointTime time.Time) SessionRestoreStatus {
+ // Local file doesn't exist (no timestamp found)
+ if localTime.IsZero() {
+ return StatusNew
+ }
+
+ // Can't determine checkpoint time - treat as new/safe
+ if checkpointTime.IsZero() {
+ return StatusNew
+ }
+
+ // Compare timestamps
+ if localTime.After(checkpointTime) {
+ return StatusLocalNewer
+ }
+ if checkpointTime.After(localTime) {
+ return StatusCheckpointNewer
+ }
+ return StatusUnchanged
+}
+
+// StatusToText returns a human-readable status string.
+func StatusToText(status SessionRestoreStatus) string {
+ switch status {
+ case StatusNew:
+ return "(new)"
+ case StatusUnchanged:
+ return "(unchanged)"
+ case StatusCheckpointNewer:
+ return "(checkpoint is newer)"
+ case StatusLocalNewer:
+ return "(local is newer)" // shouldn't appear in non-conflict list
+ default:
+ return ""
+ }
+}
+
+// PromptOverwriteNewerLogs asks the user for confirmation to overwrite local
+// session logs that have newer timestamps than the checkpoint versions.
+func PromptOverwriteNewerLogs(errW io.Writer, sessions []SessionRestoreInfo) (bool, error) {
+ if !interactive.CanPromptInteractively() {
+ return false, errors.New("cannot prompt to overwrite local session logs in non-interactive mode; rerun with --force to overwrite or use a TTY to confirm")
+ }
+
+ // Separate conflicting and non-conflicting sessions
+ var conflicting, nonConflicting []SessionRestoreInfo
+ for _, s := range sessions {
+ if s.Status == StatusLocalNewer {
+ conflicting = append(conflicting, s)
+ } else {
+ nonConflicting = append(nonConflicting, s)
+ }
+ }
+
+ fmt.Fprintf(errW, "\nWarning: Local session log(s) have newer entries than the checkpoint:\n")
+ for _, info := range conflicting {
+ // Show prompt if available, otherwise fall back to session ID
+ if info.Prompt != "" {
+ fmt.Fprintf(errW, " \"%s\"\n", info.Prompt)
+ } else {
+ fmt.Fprintf(errW, " Session: %s\n", info.SessionID)
+ }
+ fmt.Fprintf(errW, " Local last entry: %s\n", info.LocalTime.Local().Format("2006-01-02 15:04:05"))
+ fmt.Fprintf(errW, " Checkpoint last entry: %s\n", info.CheckpointTime.Local().Format("2006-01-02 15:04:05"))
+ }
+
+ // Show non-conflicting sessions with their status
+ if len(nonConflicting) > 0 {
+ fmt.Fprintf(errW, "\nThese other session(s) will also be restored:\n")
+ for _, info := range nonConflicting {
+ statusText := StatusToText(info.Status)
+ if info.Prompt != "" {
+ fmt.Fprintf(errW, " \"%s\" %s\n", info.Prompt, statusText)
+ } else {
+ fmt.Fprintf(errW, " Session: %s %s\n", info.SessionID, statusText)
+ }
+ }
+ }
+
+ fmt.Fprintf(errW, "\nOverwriting will lose the newer local entries.\n\n")
+
+ var confirmed bool
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Overwrite local session logs with checkpoint versions?").
+ Value(&confirmed),
+ ),
+ )
+ if isAccessibleMode() {
+ form = form.WithAccessible(true)
+ }
+
+ if err := form.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return false, nil
+ }
+ return false, fmt.Errorf("failed to get confirmation: %w", err)
+ }
+
+ return confirmed, nil
+}
diff --git a/cli/strategy/manual_commit_test.go b/cli/strategy/manual_commit_test.go
index e34eadc..f9ebf4d 100644
--- a/cli/strategy/manual_commit_test.go
+++ b/cli/strategy/manual_commit_test.go
@@ -1,9 +1,7 @@
package strategy
import (
- "bytes"
"context"
- "encoding/json"
"errors"
"os"
"path/filepath"
@@ -11,14 +9,8 @@ import (
"testing"
"time"
- "github.com/GrayCodeAI/trace/cli/agent"
- "github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
- "github.com/GrayCodeAI/trace/cli/paths"
- "github.com/GrayCodeAI/trace/cli/testutil"
- "github.com/GrayCodeAI/trace/cli/trailers"
- "github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
@@ -791,3968 +783,3 @@ func TestShadowStrategy_PrepareCommitMsg_NoActiveSession(t *testing.T) {
t.Errorf("PrepareCommitMsg() modified message when no session active: %q", content)
}
}
-
-func TestShadowStrategy_PrepareCommitMsg_SkipSources(t *testing.T) {
- // Tests that merge, squash, and commit sources are skipped
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- t.Chdir(dir)
-
- commitMsgFile := filepath.Join(dir, "COMMIT_MSG")
- originalMsg := "Merge branch 'feature'\n"
-
- s := NewManualCommitStrategy()
-
- skipSources := []string{"merge", "squash", "commit"}
- for _, source := range skipSources {
- t.Run(source, func(t *testing.T) {
- if err := os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644); err != nil {
- t.Fatalf("failed to write commit message file: %v", err)
- }
-
- prepErr := s.PrepareCommitMsg(context.Background(), commitMsgFile, source)
- if prepErr != nil {
- t.Errorf("PrepareCommitMsg() error = %v", prepErr)
- }
-
- // Message should be unchanged for these sources
- content, readErr := os.ReadFile(commitMsgFile)
- if readErr != nil {
- t.Fatalf("failed to read commit message file: %v", readErr)
- }
- if string(content) != originalMsg {
- t.Errorf("PrepareCommitMsg(source=%q) modified message: got %q, want %q",
- source, content, originalMsg)
- }
- })
- }
-}
-
-func TestShadowStrategy_PrepareCommitMsg_SkipsSessionWhenContentCheckFails(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
- t.Setenv("TRACE_TEST_TTY", "1")
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- err = s.InitializeSession(context.Background(), "test-session-corrupt-shadow", agent.AgentTypeClaudeCode, "", "", "")
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), "test-session-corrupt-shadow")
- require.NoError(t, err)
- require.NotNil(t, state)
-
- shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- corruptRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), plumbing.ZeroHash)
- require.NoError(t, repo.Storer.SetReference(corruptRef))
-
- commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG")
- originalMsg := "Test commit\n"
- require.NoError(t, os.WriteFile(commitMsgFile, []byte(originalMsg), 0o644))
-
- err = s.PrepareCommitMsg(context.Background(), commitMsgFile, "")
- require.NoError(t, err)
-
- content, err := os.ReadFile(commitMsgFile)
- require.NoError(t, err)
-
- _, found := trailers.ParseCheckpoint(string(content))
- require.False(t, found, "corrupt session state should not add a dangling checkpoint trailer")
- require.Equal(t, originalMsg, string(content))
-}
-
-func TestAddCheckpointTrailer_NoComment(t *testing.T) {
- // Test that addCheckpointTrailer adds trailer without any comment lines
- message := "Test commit message\n" //nolint:goconst // already present in codebase
-
- result := addCheckpointTrailer(message, testTrailerCheckpointID)
-
- // Should contain the trailer
- if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
- t.Errorf("addCheckpointTrailer() missing trailer, got: %q", result)
- }
-
- // Should NOT contain comment lines
- if strings.Contains(result, "# Remove the Trace-Checkpoint") {
- t.Errorf("addCheckpointTrailer() should not contain comment, got: %q", result)
- }
-}
-
-func TestAddCheckpointTrailerWithComment_HasComment(t *testing.T) {
- // Test that addCheckpointTrailerWithComment includes the explanatory comment
- message := "Test commit message\n"
-
- result := addCheckpointTrailerWithComment(message, testTrailerCheckpointID, "Claude Code", "add password hashing")
-
- // Should contain the trailer
- if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
- t.Errorf("addCheckpointTrailerWithComment() missing trailer, got: %q", result)
- }
-
- // Should contain comment lines with agent name (before prompt)
- if !strings.Contains(result, "# Remove the Trace-Checkpoint") {
- t.Errorf("addCheckpointTrailerWithComment() should contain comment, got: %q", result)
- }
- if !strings.Contains(result, "Claude Code session context") {
- t.Errorf("addCheckpointTrailerWithComment() should contain agent name in comment, got: %q", result)
- }
-
- // Should contain prompt line (after removal comment)
- if !strings.Contains(result, "# Last Prompt: add password hashing") {
- t.Errorf("addCheckpointTrailerWithComment() should contain prompt, got: %q", result)
- }
-
- // Verify order: Remove comment should come before Last Prompt
- removeIdx := strings.Index(result, "# Remove the Trace-Checkpoint")
- promptIdx := strings.Index(result, "# Last Prompt:")
- if promptIdx < removeIdx {
- t.Errorf("addCheckpointTrailerWithComment() prompt should come after remove comment, got: %q", result)
- }
-}
-
-func TestAddCheckpointTrailerWithComment_NoPrompt(t *testing.T) {
- // Test that addCheckpointTrailerWithComment works without a prompt
- message := "Test commit message\n"
-
- result := addCheckpointTrailerWithComment(message, testTrailerCheckpointID, "Claude Code", "")
-
- // Should contain the trailer
- if !strings.Contains(result, trailers.CheckpointTrailerKey+": "+testTrailerCheckpointID.String()) {
- t.Errorf("addCheckpointTrailerWithComment() missing trailer, got: %q", result)
- }
-
- // Should NOT contain prompt line when prompt is empty
- if strings.Contains(result, "# Last Prompt:") {
- t.Errorf("addCheckpointTrailerWithComment() should not contain prompt line when empty, got: %q", result)
- }
-
- // Should still contain the removal comment
- if !strings.Contains(result, "# Remove the Trace-Checkpoint") {
- t.Errorf("addCheckpointTrailerWithComment() should contain comment, got: %q", result)
- }
-}
-
-func TestAddCheckpointTrailer_ConventionalCommitSubject(t *testing.T) {
- t.Parallel()
-
- // Regression: single-line conventional commit subjects like "docs: Add foo"
- // contain ": " which falsely triggered the "already has trailers" detection,
- // causing the trailer to be appended without a blank line separator.
- tests := []struct {
- name string
- message string
- }{
- {
- name: "conventional commit docs",
- message: "docs: Add red.md with information about the color red\n",
- },
- {
- name: "conventional commit feat",
- message: "feat: Add new login flow\n",
- },
- {
- name: "conventional commit fix with scope",
- message: "fix(auth): Resolve token expiry issue\n",
- },
- {
- name: "single line no newline",
- message: "docs: Add something",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- result := addCheckpointTrailer(tt.message, testTrailerCheckpointID)
-
- // The trailer must be separated from the subject by a blank line
- if !strings.Contains(result, "\n\n"+trailers.CheckpointTrailerKey+":") {
- t.Errorf("addCheckpointTrailer() trailer not separated by blank line from subject.\ngot: %q", result)
- }
- })
- }
-}
-
-func TestAddCheckpointTrailer_ExistingTrailers(t *testing.T) {
- t.Parallel()
-
- // When a message already has trailers (in a separate paragraph), the
- // new trailer should be appended directly (no extra blank line).
- message := "feat: Add login\n\nSigned-off-by: Test User \n"
- result := addCheckpointTrailer(message, testTrailerCheckpointID)
-
- // Should NOT add a double blank line before our trailer
- if strings.Contains(result, "\n\n"+trailers.CheckpointTrailerKey) {
- t.Errorf("addCheckpointTrailer() added extra blank line before existing trailer block.\ngot: %q", result)
- }
-
- // Should contain both trailers
- if !strings.Contains(result, "Signed-off-by:") {
- t.Errorf("addCheckpointTrailer() lost existing trailer.\ngot: %q", result)
- }
- if !strings.Contains(result, trailers.CheckpointTrailerKey+":") {
- t.Errorf("addCheckpointTrailer() missing our trailer.\ngot: %q", result)
- }
-}
-
-func TestShadowStrategy_GetCheckpointLog_WithCheckpointID(t *testing.T) {
- // This test verifies that GetCheckpointLog correctly uses the checkpoint ID
- // to look up the log. Since getCheckpointLog requires a full git setup
- // with trace/checkpoints/v1 branch, we test the lookup logic by checking error behavior.
-
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- t.Chdir(dir)
-
- s := NewManualCommitStrategy()
-
- // Checkpoint with checkpoint ID (12 hex chars)
- checkpoint := Checkpoint{
- CheckpointID: "a1b2c3d4e5f6",
- Message: "Checkpoint: a1b2c3d4e5f6",
- Timestamp: time.Now(),
- }
-
- // This should attempt to call getCheckpointLog (which will fail because
- // there's no trace/checkpoints/v1 branch), but the important thing is it uses
- // the checkpoint ID to look up metadata
- _, err = s.GetCheckpointLog(context.Background(), checkpoint)
- if err == nil {
- t.Error("GetCheckpointLog() expected error (no sessions branch), got nil")
- }
- // The error should be about sessions branch, not about parsing
- if err != nil && err.Error() != "sessions branch not found" {
- t.Logf("GetCheckpointLog() error = %v (expected sessions branch error)", err)
- }
-}
-
-func TestShadowStrategy_GetCheckpointLog_NoCheckpointID(t *testing.T) {
- // Test that checkpoints without checkpoint ID return ErrNoMetadata
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- t.Chdir(dir)
-
- s := NewManualCommitStrategy()
-
- // Checkpoint without checkpoint ID
- checkpoint := Checkpoint{
- CheckpointID: "",
- Message: "Some other message",
- Timestamp: time.Now(),
- }
-
- // This should return ErrNoMetadata since there's no checkpoint ID
- _, err = s.GetCheckpointLog(context.Background(), checkpoint)
- if err == nil {
- t.Error("GetCheckpointLog() expected error for missing checkpoint ID, got nil")
- }
- if !errors.Is(err, ErrNoMetadata) {
- t.Errorf("GetCheckpointLog() expected ErrNoMetadata, got %v", err)
- }
-}
-
-func TestShadowStrategy_FilesTouched_OnlyModifiedFiles(t *testing.T) {
- // This test verifies that files_touched only contains files that were actually
- // modified during the session, not ALL files in the repository.
- //
- // The fix tracks files in SessionState.FilesTouched as they are modified,
- // rather than collecting all files from the shadow branch tree.
-
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create initial commit with multiple pre-existing files
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create 3 pre-existing files that should NOT be in files_touched
- preExistingFiles := []string{"existing1.txt", "existing2.txt", "existing3.txt"}
- for _, f := range preExistingFiles {
- filePath := filepath.Join(dir, f)
- if err := os.WriteFile(filePath, []byte("original content of "+f), 0o644); err != nil {
- t.Fatalf("failed to write file %s: %v", f, err)
- }
- if _, err := worktree.Add(f); err != nil {
- t.Fatalf("failed to add file %s: %v", f, err)
- }
- }
-
- _, err = worktree.Commit("Initial commit with pre-existing files", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-session-123"
-
- // Create metadata directory with a transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- // Write transcript file (minimal valid JSONL)
- transcript := `{"type":"human","message":{"content":"modify existing1.txt"}}
-{"type":"assistant","message":{"content":"I'll modify existing1.txt for you."}}
-`
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // First checkpoint using SaveStep - captures ALL working directory files
- // (for rewind purposes), but tracks only modified files in FilesTouched
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{}, // No files modified yet
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Now simulate a second checkpoint where ONLY existing1.txt is modified
- // (but NOT existing2.txt or existing3.txt)
- modifiedContent := []byte("MODIFIED content of existing1.txt")
- if err := os.WriteFile(filepath.Join(dir, "existing1.txt"), modifiedContent, 0o644); err != nil {
- t.Fatalf("failed to modify existing1.txt: %v", err)
- }
-
- // Second checkpoint using SaveStep - only modified file should be tracked
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"existing1.txt"}, // Only this file was modified
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Load session state to verify FilesTouched
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
-
- // Now condense the session
- checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Verify that files_touched only contains the file that was actually modified
- expectedFilesTouched := []string{"existing1.txt"}
-
- // Check what we actually got
- if len(result.FilesTouched) != len(expectedFilesTouched) {
- t.Errorf("FilesTouched contains %d files, want %d.\nGot: %v\nWant: %v",
- len(result.FilesTouched), len(expectedFilesTouched),
- result.FilesTouched, expectedFilesTouched)
- }
-
- // Verify the exact content
- filesTouchedMap := make(map[string]bool)
- for _, f := range result.FilesTouched {
- filesTouchedMap[f] = true
- }
-
- // Check that ONLY the modified file is in files_touched
- for _, expected := range expectedFilesTouched {
- if !filesTouchedMap[expected] {
- t.Errorf("Expected file %q to be in files_touched, but it was not. Got: %v", expected, result.FilesTouched)
- }
- }
-
- // Check that pre-existing unmodified files are NOT in files_touched
- unmodifiedFiles := []string{"existing2.txt", "existing3.txt"}
- for _, unmodified := range unmodifiedFiles {
- if filesTouchedMap[unmodified] {
- t.Errorf("File %q should NOT be in files_touched (it was not modified during the session), but it was included. Got: %v",
- unmodified, result.FilesTouched)
- }
- }
-}
-
-// TestDeleteShadowBranch verifies that deleteShadowBranch correctly deletes a shadow branch.
-func TestDeleteShadowBranch(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- t.Chdir(dir)
-
- // Create a dummy commit to use as branch target
- emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
- dummyCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com")
- if err != nil {
- t.Fatalf("failed to create dummy commit: %v", err)
- }
-
- // Create a shadow branch
- shadowBranchName := "trace/abc1234"
- refName := plumbing.NewBranchReferenceName(shadowBranchName)
- ref := plumbing.NewHashReference(refName, dummyCommitHash)
- if err := repo.Storer.SetReference(ref); err != nil {
- t.Fatalf("failed to create shadow branch: %v", err)
- }
-
- // Verify branch exists
- _, err = repo.Reference(refName, true)
- if err != nil {
- t.Fatalf("shadow branch should exist: %v", err)
- }
-
- // Delete the shadow branch
- err = deleteShadowBranch(context.Background(), repo, shadowBranchName)
- if err != nil {
- t.Fatalf("deleteShadowBranch() error = %v", err)
- }
-
- // Verify branch is deleted
- _, err = repo.Reference(refName, true)
- if err == nil {
- t.Error("shadow branch should be deleted, but still exists")
- }
-}
-
-// TestDeleteShadowBranch_NonExistent verifies that deleting a non-existent branch is idempotent.
-func TestDeleteShadowBranch_NonExistent(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- t.Chdir(dir)
-
- // Try to delete a branch that doesn't exist - should not error
- err = deleteShadowBranch(context.Background(), repo, "trace/nonexistent")
- if err != nil {
- t.Errorf("deleteShadowBranch() for non-existent branch should not error, got: %v", err)
- }
-}
-
-// TestSessionState_LastCheckpointID verifies that LastCheckpointID is persisted correctly.
-func TestSessionState_LastCheckpointID(t *testing.T) {
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- // Create session state with LastCheckpointID
- state := &SessionState{
- SessionID: "test-session-123",
- BaseCommit: "abc123def456",
- StartedAt: time.Now(),
- StepCount: 5,
- LastCheckpointID: "a1b2c3d4e5f6",
- }
-
- // Save state
- err = s.saveSessionState(context.Background(), state)
- if err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- // Load state and verify LastCheckpointID
- loaded, err := s.loadSessionState(context.Background(), "test-session-123")
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- require.NotNil(t, loaded, "loadSessionState() returned nil")
-
- if loaded.LastCheckpointID != state.LastCheckpointID {
- t.Errorf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, state.LastCheckpointID)
- }
-}
-
-// TestSessionState_TokenUsagePersistence verifies that token usage fields are persisted correctly
-// across session state save/load cycles. This is critical for tracking token usage in the
-// manual-commit strategy where session state is persisted to disk between checkpoints.
-func TestSessionState_TokenUsagePersistence(t *testing.T) {
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- // Create session state with token usage fields
- state := &SessionState{
- SessionID: "test-session-token-usage",
- BaseCommit: "abc123def456",
- StartedAt: time.Now(),
- StepCount: 5,
- CheckpointTranscriptStart: 42,
- TranscriptIdentifierAtStart: "test-uuid-abc123",
- TokenUsage: &agent.TokenUsage{
- InputTokens: 1000,
- CacheCreationTokens: 200,
- CacheReadTokens: 300,
- OutputTokens: 500,
- APICallCount: 5,
- },
- }
-
- // Save state
- err = s.saveSessionState(context.Background(), state)
- if err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- // Load state and verify token usage fields are persisted
- loaded, err := s.loadSessionState(context.Background(), "test-session-token-usage")
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- require.NotNil(t, loaded, "loadSessionState() returned nil")
-
- // Verify CheckpointTranscriptStart
- if loaded.CheckpointTranscriptStart != state.CheckpointTranscriptStart {
- t.Errorf("CheckpointTranscriptStart = %d, want %d", loaded.CheckpointTranscriptStart, state.CheckpointTranscriptStart)
- }
-
- // Verify TranscriptIdentifierAtStart
- if loaded.TranscriptIdentifierAtStart != state.TranscriptIdentifierAtStart {
- t.Errorf("TranscriptIdentifierAtStart = %q, want %q", loaded.TranscriptIdentifierAtStart, state.TranscriptIdentifierAtStart)
- }
-
- // Verify TokenUsage
- if loaded.TokenUsage == nil {
- t.Fatal("TokenUsage should be persisted, got nil")
- }
- if loaded.TokenUsage.InputTokens != state.TokenUsage.InputTokens {
- t.Errorf("TokenUsage.InputTokens = %d, want %d", loaded.TokenUsage.InputTokens, state.TokenUsage.InputTokens)
- }
- if loaded.TokenUsage.CacheCreationTokens != state.TokenUsage.CacheCreationTokens {
- t.Errorf("TokenUsage.CacheCreationTokens = %d, want %d", loaded.TokenUsage.CacheCreationTokens, state.TokenUsage.CacheCreationTokens)
- }
- if loaded.TokenUsage.CacheReadTokens != state.TokenUsage.CacheReadTokens {
- t.Errorf("TokenUsage.CacheReadTokens = %d, want %d", loaded.TokenUsage.CacheReadTokens, state.TokenUsage.CacheReadTokens)
- }
- if loaded.TokenUsage.OutputTokens != state.TokenUsage.OutputTokens {
- t.Errorf("TokenUsage.OutputTokens = %d, want %d", loaded.TokenUsage.OutputTokens, state.TokenUsage.OutputTokens)
- }
- if loaded.TokenUsage.APICallCount != state.TokenUsage.APICallCount {
- t.Errorf("TokenUsage.APICallCount = %d, want %d", loaded.TokenUsage.APICallCount, state.TokenUsage.APICallCount)
- }
-}
-
-// TestShadowStrategy_PrepareCommitMsg_ReusesLastCheckpointID verifies that PrepareCommitMsg
-// reuses the LastCheckpointID when there's no new content to condense.
-func TestShadowStrategy_PrepareCommitMsg_ReusesLastCheckpointID(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create initial commit
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
- t.Fatalf("failed to write test file: %v", err)
- }
- if _, err := worktree.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- // Create session state with LastCheckpointID but no new content
- // (simulating state after first commit with condensation)
- state := &SessionState{
- SessionID: "test-session",
- BaseCommit: initialCommit.String(),
- WorktreePath: dir,
- StartedAt: time.Now(),
- StepCount: 1,
- CheckpointTranscriptStart: 10, // Already condensed
- LastCheckpointID: testTrailerCheckpointID,
- }
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- // Note: We can't fully test PrepareCommitMsg without setting up a shadow branch
- // with transcript, but we can verify the session state has LastCheckpointID set
- // The actual behavior is tested through integration tests
-
- // Verify the state was saved correctly
- loaded, err := s.loadSessionState(context.Background(), "test-session")
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if loaded.LastCheckpointID != testTrailerCheckpointID {
- t.Errorf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
- }
-}
-
-func TestParsePostRewritePairs(t *testing.T) {
- pairs, err := parsePostRewritePairs(strings.NewReader("oldsha newsha\n\nold2 new2\n"))
- if err != nil {
- t.Fatalf("parsePostRewritePairs() error = %v", err)
- }
- if len(pairs) != 2 {
- t.Fatalf("len(pairs) = %d, want 2", len(pairs))
- }
- if pairs[0].OldSHA != "oldsha" || pairs[0].NewSHA != "newsha" {
- t.Fatalf("pairs[0] = %+v, want oldsha->newsha", pairs[0])
- }
- if pairs[1].OldSHA != "old2" || pairs[1].NewSHA != "new2" {
- t.Fatalf("pairs[1] = %+v, want old2->new2", pairs[1])
- }
-}
-
-func TestParsePostRewritePairs_AllowsOptionalExtraField(t *testing.T) {
- pairs, err := parsePostRewritePairs(strings.NewReader("oldsha newsha extra-info\n"))
- if err != nil {
- t.Fatalf("parsePostRewritePairs() error = %v", err)
- }
- if len(pairs) != 1 {
- t.Fatalf("len(pairs) = %d, want 1", len(pairs))
- }
- if pairs[0].OldSHA != "oldsha" || pairs[0].NewSHA != "newsha" {
- t.Fatalf("pairs[0] = %+v, want oldsha->newsha", pairs[0])
- }
-}
-
-func TestParsePostRewritePairs_InvalidLine(t *testing.T) {
- _, err := parsePostRewritePairs(strings.NewReader("missing-second-column\n"))
- if err == nil {
- t.Fatal("parsePostRewritePairs() error = nil, want error")
- }
-}
-
-func TestShadowStrategy_PostRewrite_RemapsMatchingSessionInWorktree(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- t.Chdir(dir)
- oldSHA := strings.Repeat("a", 40)
- newSHA := strings.Repeat("b", 40)
- worktreePath, err := paths.WorktreeRoot(context.Background())
- if err != nil {
- t.Fatalf("WorktreeRoot() error = %v", err)
- }
-
- s := &ManualCommitStrategy{}
- state := &SessionState{
- SessionID: "session-1",
- BaseCommit: oldSHA,
- AttributionBaseCommit: oldSHA,
- WorktreePath: worktreePath,
- StartedAt: time.Now(),
- LastCheckpointID: testTrailerCheckpointID,
- }
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldSHA+" "+newSHA+"\n")); err != nil {
- t.Fatalf("PostRewrite() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), state.SessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if loaded.BaseCommit != newSHA {
- t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newSHA)
- }
- if loaded.AttributionBaseCommit != newSHA {
- t.Fatalf("AttributionBaseCommit = %q, want %q", loaded.AttributionBaseCommit, newSHA)
- }
- if loaded.LastCheckpointID != testTrailerCheckpointID {
- t.Fatalf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
- }
-}
-
-func TestShadowStrategy_PostRewrite_MigratesExistingShadowBranch(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "tracked.txt", "one\n")
- testutil.GitAdd(t, dir, "tracked.txt")
- testutil.GitCommit(t, dir, "initial")
- t.Chdir(dir)
-
- repo, err := OpenRepository(context.Background())
- if err != nil {
- t.Fatalf("OpenRepository() error = %v", err)
- }
- head, err := repo.Head()
- if err != nil {
- t.Fatalf("Head() error = %v", err)
- }
- oldBaseCommit := head.Hash().String()
-
- testutil.WriteFile(t, dir, "tracked.txt", "two\n")
- testutil.GitAdd(t, dir, "tracked.txt")
- testutil.GitCommit(t, dir, "second")
- head, err = repo.Head()
- if err != nil {
- t.Fatalf("Head() after second commit error = %v", err)
- }
- newBaseCommit := head.Hash().String()
-
- worktreePath, err := paths.WorktreeRoot(context.Background())
- if err != nil {
- t.Fatalf("WorktreeRoot() error = %v", err)
- }
- worktreeID, err := paths.GetWorktreeID(worktreePath)
- if err != nil {
- t.Fatalf("GetWorktreeID() error = %v", err)
- }
-
- oldShadowBranch := checkpoint.ShadowBranchNameForCommit(oldBaseCommit, worktreeID)
- newShadowBranch := checkpoint.ShadowBranchNameForCommit(newBaseCommit, worktreeID)
- oldShadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(oldShadowBranch), plumbing.NewHash(oldBaseCommit))
- if err := repo.Storer.SetReference(oldShadowRef); err != nil {
- t.Fatalf("SetReference(old shadow) error = %v", err)
- }
-
- s := &ManualCommitStrategy{}
- state := &SessionState{
- SessionID: "session-1",
- BaseCommit: oldBaseCommit,
- AttributionBaseCommit: oldBaseCommit,
- WorktreePath: worktreePath,
- WorktreeID: worktreeID,
- StartedAt: time.Now(),
- LastCheckpointID: testTrailerCheckpointID,
- }
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldBaseCommit+" "+newBaseCommit+" extra\n")); err != nil {
- t.Fatalf("PostRewrite() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), state.SessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if loaded.BaseCommit != newBaseCommit {
- t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newBaseCommit)
- }
- if loaded.AttributionBaseCommit != oldBaseCommit {
- t.Fatalf("AttributionBaseCommit = %q, want original %q when shadow branch migrates", loaded.AttributionBaseCommit, oldBaseCommit)
- }
- if !referenceExists(t, repo, plumbing.NewBranchReferenceName(newShadowBranch)) {
- t.Fatalf("expected migrated shadow branch %q to exist", newShadowBranch)
- }
- if referenceExists(t, repo, plumbing.NewBranchReferenceName(oldShadowBranch)) {
- t.Fatalf("expected old shadow branch %q to be removed", oldShadowBranch)
- }
-}
-
-func TestShadowStrategy_MigrateAndPersistIfNeeded_PersistsBaseCommitWithoutShadowBranch(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "tracked.txt", "one\n")
- testutil.GitAdd(t, dir, "tracked.txt")
- testutil.GitCommit(t, dir, "initial")
- t.Chdir(dir)
-
- repo, err := OpenRepository(context.Background())
- if err != nil {
- t.Fatalf("OpenRepository() error = %v", err)
- }
- head, err := repo.Head()
- if err != nil {
- t.Fatalf("Head() error = %v", err)
- }
- oldBaseCommit := head.Hash().String()
-
- testutil.WriteFile(t, dir, "tracked.txt", "two\n")
- testutil.GitAdd(t, dir, "tracked.txt")
- testutil.GitCommit(t, dir, "second")
- head, err = repo.Head()
- if err != nil {
- t.Fatalf("Head() after second commit error = %v", err)
- }
- newBaseCommit := head.Hash().String()
-
- worktreePath, err := paths.WorktreeRoot(context.Background())
- if err != nil {
- t.Fatalf("WorktreeRoot() error = %v", err)
- }
-
- s := &ManualCommitStrategy{}
- state := &SessionState{
- SessionID: "session-1",
- BaseCommit: oldBaseCommit,
- AttributionBaseCommit: oldBaseCommit,
- WorktreePath: worktreePath,
- StartedAt: time.Now(),
- LastCheckpointID: testTrailerCheckpointID,
- }
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- if err := s.migrateAndPersistIfNeeded(context.Background(), repo, state); err != nil {
- t.Fatalf("migrateAndPersistIfNeeded() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), state.SessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if loaded.BaseCommit != newBaseCommit {
- t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, newBaseCommit)
- }
-}
-
-func TestShadowStrategy_PostRewrite_DoesNotTouchOtherWorktrees(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- t.Chdir(dir)
- oldSHA := strings.Repeat("a", 40)
- newSHA := strings.Repeat("b", 40)
-
- s := &ManualCommitStrategy{}
- other := &SessionState{
- SessionID: "other-worktree",
- BaseCommit: oldSHA,
- AttributionBaseCommit: oldSHA,
- WorktreePath: filepath.Join(dir, "other"),
- StartedAt: time.Now(),
- LastCheckpointID: testTrailerCheckpointID,
- }
- if err := s.saveSessionState(context.Background(), other); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- if err := s.PostRewrite(context.Background(), "amend", strings.NewReader(oldSHA+" "+newSHA+"\n")); err != nil {
- t.Fatalf("PostRewrite() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), other.SessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if loaded.BaseCommit != oldSHA {
- t.Fatalf("BaseCommit = %q, want %q", loaded.BaseCommit, oldSHA)
- }
- if loaded.AttributionBaseCommit != oldSHA {
- t.Fatalf("AttributionBaseCommit = %q, want %q", loaded.AttributionBaseCommit, oldSHA)
- }
- if loaded.LastCheckpointID != testTrailerCheckpointID {
- t.Fatalf("LastCheckpointID = %q, want %q", loaded.LastCheckpointID, testTrailerCheckpointID)
- }
-}
-
-func referenceExists(t *testing.T, repo *git.Repository, refName plumbing.ReferenceName) bool {
- t.Helper()
-
- _, err := repo.Reference(refName, true)
- return err == nil
-}
-
-// TestShadowStrategy_CondenseSession_EphemeralBranchTrailer verifies that checkpoint commits
-// on the trace/checkpoints/v1 branch include the Ephemeral-branch trailer indicating which shadow
-// branch the checkpoint originated from.
-func TestShadowStrategy_CondenseSession_EphemeralBranchTrailer(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- // Create initial commit with a file
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- initialFile := filepath.Join(dir, "initial.txt")
- if err := os.WriteFile(initialFile, []byte("initial content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("initial.txt"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
-
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-session-ephemeral"
-
- // Create metadata directory with transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(testTranscriptPromptResponse), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Use SaveStep to create a checkpoint (this creates the shadow branch)
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Load session state
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
-
- // Condense the session
- checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
- _, err = s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Get the sessions branch commit and verify the Ephemeral-branch trailer
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch reference: %v", err)
- }
-
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
-
- // Verify the commit message contains the Ephemeral-branch trailer
- shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- expectedTrailer := "Ephemeral-branch: " + shadowBranchName
- if !strings.Contains(sessionsCommit.Message, expectedTrailer) {
- t.Errorf("sessions branch commit should contain %q trailer, got message:\n%s", expectedTrailer, sessionsCommit.Message)
- }
-}
-
-// TestSaveStep_EmptyBaseCommit_Recovery verifies that SaveStep recovers gracefully
-// when a session state exists with empty BaseCommit (can happen from concurrent warning state).
-func TestSaveStep_EmptyBaseCommit_Recovery(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
-
- // Create initial commit
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-empty-basecommit-test"
-
- // Create a partial session state with empty BaseCommit
- // (simulates a partial session state with empty BaseCommit)
- partialState := &SessionState{
- SessionID: sessionID,
- BaseCommit: "", // Empty! This is the bug scenario
- StartedAt: time.Now(),
- }
- if err := s.saveSessionState(context.Background(), partialState); err != nil {
- t.Fatalf("failed to save partial state: %v", err)
- }
-
- // Create metadata directory
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // SaveStep should recover by re-initializing the session state
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Test checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() should recover from empty BaseCommit, got error: %v", err)
- }
-
- // Verify session state now has a valid BaseCommit
- loaded, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("failed to load session state: %v", err)
- }
- if loaded.BaseCommit == "" {
- t.Error("BaseCommit should be populated after recovery")
- }
- if loaded.StepCount != 1 {
- t.Errorf("StepCount = %d, want 1", loaded.StepCount)
- }
-}
-
-// TestSaveStep_UsesCtxAgentType_WhenNoSessionState tests that SaveStep uses
-// ctx.AgentType when no session state exists.
-func TestSaveStep_UsesCtxAgentType_WhenNoSessionState(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2026-02-06-agent-type-test"
-
- // NO session state exists (simulates InitializeSession failure)
- // SaveStep should use ctx.AgentType
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Test checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- AgentType: agent.AgentTypeClaudeCode,
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("failed to load session state: %v", err)
- }
- if loaded.AgentType != agent.AgentTypeClaudeCode {
- t.Errorf("AgentType = %q, want %q", loaded.AgentType, agent.AgentTypeClaudeCode)
- }
-}
-
-// TestSaveStep_UsesCtxAgentType_WhenPartialState tests that SaveStep uses
-// ctx.AgentType when a partial session state exists (empty BaseCommit and AgentType).
-func TestSaveStep_UsesCtxAgentType_WhenPartialState(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init git repo: %v", err)
- }
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("test.txt"); err != nil {
- t.Fatalf("failed to add file: %v", err)
- }
- if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2026-02-06-partial-state-agent-test"
-
- // Create partial session state with empty BaseCommit and no AgentType
- partialState := &SessionState{
- SessionID: sessionID,
- BaseCommit: "",
- StartedAt: time.Now(),
- }
- if err := s.saveSessionState(context.Background(), partialState); err != nil {
- t.Fatalf("failed to save partial state: %v", err)
- }
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
- transcript := `{"type":"human","message":{"content":"test"}}` + "\n"
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Test checkpoint",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- AgentType: agent.AgentTypeClaudeCode,
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- loaded, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("failed to load session state: %v", err)
- }
- if loaded.AgentType != agent.AgentTypeClaudeCode {
- t.Errorf("AgentType = %q, want %q", loaded.AgentType, agent.AgentTypeClaudeCode)
- }
-}
-
-// TestCountTranscriptItems tests counting lines/messages in different transcript formats.
-func TestCountTranscriptItems(t *testing.T) {
- tests := []struct {
- name string
- agentType types.AgentType
- content string
- expected int
- }{
- {
- name: "Gemini JSON with messages",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "user", "content": "Hello"},
- {"type": "gemini", "content": "Hi there!"}
- ]
- }`,
- expected: 2,
- },
- {
- name: "Gemini empty messages array",
- agentType: agent.AgentTypeGemini,
- content: `{"messages": []}`,
- expected: 0,
- },
- {
- name: "Claude Code JSONL",
- agentType: agent.AgentTypeClaudeCode,
- content: `{"type":"human","message":{"content":"Hello"}}
-{"type":"assistant","message":{"content":"Hi"}}`,
- expected: 2,
- },
- {
- name: "Claude Code JSONL with trailing newline",
- agentType: agent.AgentTypeClaudeCode,
- content: `{"type":"human","message":{"content":"Hello"}}
-{"type":"assistant","message":{"content":"Hi"}}
-`,
- expected: 2,
- },
- {
- name: "empty string",
- agentType: agent.AgentTypeClaudeCode,
- content: "",
- expected: 0,
- },
- {
- name: "Gemini JSON with array content (real format)",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "user", "content": [{"text": "Hello"}]},
- {"type": "gemini", "content": "Hi there!"},
- {"type": "user", "content": [{"text": "Do something"}]},
- {"type": "gemini", "content": "Done!"}
- ]
- }`,
- expected: 4,
- },
- {
- name: "OpenCode export JSON with messages",
- agentType: agent.AgentTypeOpenCode,
- content: `{
- "info": {"id": "session-1"},
- "messages": [
- {"info": {"role": "user"}, "parts": [{"type": "text", "text": "Hello"}]},
- {"info": {"role": "assistant"}, "parts": [{"type": "text", "text": "Hi there!"}]}
- ]
- }`,
- expected: 2,
- },
- {
- name: "OpenCode export JSON empty messages",
- agentType: agent.AgentTypeOpenCode,
- content: `{"info": {"id": "session-1"}, "messages": []}`,
- expected: 0,
- },
- {
- name: "OpenCode invalid JSON",
- agentType: agent.AgentTypeOpenCode,
- content: `not valid json`,
- expected: 0,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := countTranscriptItems(tt.agentType, tt.content)
- if result != tt.expected {
- t.Errorf("countTranscriptItems() = %v, want %v", result, tt.expected)
- }
- })
- }
-}
-
-// TestExtractUserPrompts tests extraction of user prompts from different transcript formats.
-func TestExtractUserPrompts(t *testing.T) {
- tests := []struct {
- name string
- agentType types.AgentType
- content string
- expected []string
- }{
- {
- name: "Gemini single user prompt",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "user", "content": "Create a file called test.txt"}
- ]
- }`,
- expected: []string{"Create a file called test.txt"},
- },
- {
- name: "Gemini multiple user prompts",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "user", "content": "First prompt"},
- {"type": "gemini", "content": "Response 1"},
- {"type": "user", "content": "Second prompt"},
- {"type": "gemini", "content": "Response 2"}
- ]
- }`,
- expected: []string{"First prompt", "Second prompt"},
- },
- {
- name: "Gemini no user messages",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "gemini", "content": "Hello!"}
- ]
- }`,
- expected: nil,
- },
- {
- name: "Claude Code JSONL with user messages",
- agentType: agent.AgentTypeClaudeCode,
- content: `{"type":"user","message":{"content":"Hello"}}
-{"type":"assistant","message":{"content":"Hi"}}
-{"type":"user","message":{"content":"Goodbye"}}`,
- expected: []string{"Hello", "Goodbye"},
- },
- {
- name: "empty string",
- agentType: agent.AgentTypeClaudeCode,
- content: "",
- expected: nil,
- },
- {
- name: "Gemini array content (real format)",
- agentType: agent.AgentTypeGemini,
- content: `{
- "messages": [
- {"type": "user", "content": [{"text": "Create a file"}]},
- {"type": "gemini", "content": "Done!"},
- {"type": "user", "content": [{"text": "Edit the file"}]},
- {"type": "gemini", "content": "Updated!"}
- ]
- }`,
- expected: []string{"Create a file", "Edit the file"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := extractUserPrompts(tt.agentType, tt.content)
- if len(result) != len(tt.expected) {
- t.Errorf("extractUserPrompts() returned %d prompts, want %d", len(result), len(tt.expected))
- return
- }
- for i, prompt := range result {
- if prompt != tt.expected[i] {
- t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i])
- }
- }
- })
- }
-}
-
-// TestCondenseSession_IncludesInitialAttribution verifies that when manual-commit
-// condenses a session, it calculates InitialAttribution by comparing the shadow branch
-// (agent work) to HEAD (what was committed).
-func TestCondenseSession_IncludesInitialAttribution(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- // Create initial commit with a file
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create a file with some content
- testFile := filepath.Join(dir, "test.go")
- originalContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"
- if err := os.WriteFile(testFile, []byte(originalContent), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("test.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
-
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-attribution"
-
- // Create metadata directory with transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- transcript := `{"type":"human","message":{"content":"modify test.go"}}
-{"type":"assistant","message":{"content":"I'll modify test.go"}}
-`
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Agent modifies the file (adds a new function)
- agentContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n\nfunc newFunc() {\n\tprintln(\"agent added this\")\n}\n"
- if err := os.WriteFile(testFile, []byte(agentContent), 0o644); err != nil {
- t.Fatalf("failed to write agent changes: %v", err)
- }
-
- // First checkpoint - captures agent's work on shadow branch
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.go"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Human edits the file (adds a comment)
- humanEditedContent := "package main\n\n// Human added this comment\nfunc main() {\n\tprintln(\"hello\")\n}\n\nfunc newFunc() {\n\tprintln(\"agent added this\")\n}\n"
- if err := os.WriteFile(testFile, []byte(humanEditedContent), 0o644); err != nil {
- t.Fatalf("failed to write human edits: %v", err)
- }
-
- // Stage and commit the human-edited file (this is what the user does)
- if _, err := worktree.Add("test.go"); err != nil {
- t.Fatalf("failed to stage human edits: %v", err)
- }
- _, err = worktree.Commit("Add new function with human comment", &git.CommitOptions{
- Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit human edits: %v", err)
- }
-
- // Load session state
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
-
- // Condense the session - this should calculate InitialAttribution
- checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Verify CondenseResult
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
- }
-
- // Read metadata from trace/checkpoints/v1 branch and verify InitialAttribution
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
-
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
-
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // InitialAttribution is stored in session-level metadata (0/metadata.json), not root (0-based indexing)
- sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(sessionMetadataPath)
- if err != nil {
- t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
- }
-
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata.json: %v", err)
- }
-
- // Parse and verify InitialAttribution is present
- var metadata struct {
- InitialAttribution *struct {
- AgentLines int `json:"agent_lines"`
- HumanAdded int `json:"human_added"`
- HumanModified int `json:"human_modified"`
- HumanRemoved int `json:"human_removed"`
- TotalCommitted int `json:"total_committed"`
- AgentPercentage float64 `json:"agent_percentage"`
- } `json:"initial_attribution"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata.json: %v", err)
- }
-
- if metadata.InitialAttribution == nil {
- t.Fatal("InitialAttribution should be present in session metadata.json for manual-commit")
- }
-
- // Verify the attribution values are reasonable
- // Agent added new function, human added a comment line
- // The exact line counts depend on how the diff algorithm interprets the changes
- // (insertion vs modification), but we should have non-zero totals and reasonable percentages.
- if metadata.InitialAttribution.TotalCommitted == 0 {
- t.Error("TotalCommitted should be > 0")
- }
- if metadata.InitialAttribution.AgentLines == 0 {
- t.Error("AgentLines should be > 0 (agent wrote code)")
- }
-
- // Human contribution should be captured in either HumanAdded or HumanModified
- // When inserting lines in the middle of existing code, the diff algorithm may
- // interpret it as a modification rather than a pure addition.
- humanContribution := metadata.InitialAttribution.HumanAdded + metadata.InitialAttribution.HumanModified
- if humanContribution == 0 {
- t.Error("Human contribution (HumanAdded + HumanModified) should be > 0")
- }
-
- if metadata.InitialAttribution.AgentPercentage <= 0 || metadata.InitialAttribution.AgentPercentage > 100 {
- t.Errorf("AgentPercentage should be between 0-100, got %f", metadata.InitialAttribution.AgentPercentage)
- }
-
- t.Logf("Attribution: agent=%d, human_added=%d, human_modified=%d, human_removed=%d, total=%d, percentage=%.1f%%",
- metadata.InitialAttribution.AgentLines,
- metadata.InitialAttribution.HumanAdded,
- metadata.InitialAttribution.HumanModified,
- metadata.InitialAttribution.HumanRemoved,
- metadata.InitialAttribution.TotalCommitted,
- metadata.InitialAttribution.AgentPercentage)
-}
-
-// TestCondenseSession_AttributionWithoutShadowBranch verifies that when an agent
-// commits mid-turn (before SaveStep), attribution is still calculated using HEAD
-// as the shadow tree. This reproduces the bug where agent_lines=0 for mid-turn commits.
-func TestCondenseSession_AttributionWithoutShadowBranch(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial empty commit
- initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- AllowEmptyCommits: true,
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Agent creates files in nested directories and commits (mid-turn, no SaveStep)
- srcDir := filepath.Join(dir, "src")
- if err := os.MkdirAll(srcDir, 0o755); err != nil {
- t.Fatalf("failed to create src dir: %v", err)
- }
- agentFile := filepath.Join(srcDir, "main.go")
- agentContent := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n"
- if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil {
- t.Fatalf("failed to write agent file: %v", err)
- }
- agentFile2 := filepath.Join(dir, "README.md")
- agentContent2 := "# My Project\n\nA test project.\n"
- if err := os.WriteFile(agentFile2, []byte(agentContent2), 0o644); err != nil {
- t.Fatalf("failed to write agent file 2: %v", err)
- }
- if _, err := worktree.Add("src/main.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- if _, err := worktree.Add("README.md"); err != nil {
- t.Fatalf("failed to stage file 2: %v", err)
- }
- _, err = worktree.Commit("Add project files", &git.CommitOptions{
- Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- // Create a live transcript file (required when no shadow branch)
- transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
- if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
- t.Fatalf("failed to create transcript dir: %v", err)
- }
- transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
- transcriptContent := `{"type":"human","message":{"content":"create project files"}}
-{"type":"assistant","message":{"content":"I'll create src/main.go and README.md"}}
-`
- if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Construct session state manually (no SaveStep was called, so no shadow branch)
- state := &SessionState{
- SessionID: "test-no-shadow",
- BaseCommit: initialHash.String(),
- AttributionBaseCommit: initialHash.String(),
- FilesTouched: []string{"src/main.go", "README.md"},
- TranscriptPath: transcriptFile,
- AgentType: "Claude Code",
- }
-
- s := &ManualCommitStrategy{}
- checkpointID := id.MustCheckpointID("c3d4e5f6a7b8")
-
- // Condense — no shadow branch exists, but attribution should still work
- committedFiles := map[string]struct{}{"src/main.go": {}, "README.md": {}}
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
- }
-
- // Read metadata from trace/checkpoints/v1 branch
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(sessionMetadataPath)
- if err != nil {
- t.Fatalf("failed to find session metadata at %s: %v", sessionMetadataPath, err)
- }
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata: %v", err)
- }
-
- var metadata struct {
- InitialAttribution *struct {
- AgentLines int `json:"agent_lines"`
- HumanAdded int `json:"human_added"`
- TotalCommitted int `json:"total_committed"`
- AgentPercentage float64 `json:"agent_percentage"`
- } `json:"initial_attribution"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata: %v", err)
- }
-
- if metadata.InitialAttribution == nil {
- t.Fatal("InitialAttribution should be present even without shadow branch")
- }
-
- // Agent created all content (10 lines across 2 files), no human edits
- if metadata.InitialAttribution.AgentLines == 0 {
- t.Error("AgentLines should be > 0 (agent created the file)")
- }
- if metadata.InitialAttribution.TotalCommitted == 0 {
- t.Error("TotalCommitted should be > 0")
- }
- if metadata.InitialAttribution.AgentPercentage <= 50 {
- t.Errorf("AgentPercentage should be > 50%% (agent wrote all content), got %.1f%%",
- metadata.InitialAttribution.AgentPercentage)
- }
-
- t.Logf("Attribution (no shadow branch): agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
- metadata.InitialAttribution.AgentLines,
- metadata.InitialAttribution.HumanAdded,
- metadata.InitialAttribution.TotalCommitted,
- metadata.InitialAttribution.AgentPercentage)
-}
-
-// TestCondenseSession_AttributionWithoutShadowBranch_MixedHumanAgent verifies attribution
-// when an agent commits mid-turn (no shadow branch) and the commit includes both human
-// pre-session changes and agent-created files. Human changes are captured in PromptAttributions
-// and should be subtracted from the total to isolate agent contribution.
-func TestCondenseSession_AttributionWithoutShadowBranch_MixedHumanAgent(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit with one file
- existingFile := filepath.Join(dir, "config.yaml")
- if err := os.WriteFile(existingFile, []byte("key: value\n"), 0o644); err != nil {
- t.Fatalf("failed to write initial file: %v", err)
- }
- if _, err := wt.Add("config.yaml"); err != nil {
- t.Fatalf("failed to stage: %v", err)
- }
- initialHash, err := wt.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // Human adds a new file (before the agent session starts).
- // This is captured by calculatePromptAttributionAtStart.
- humanFile := filepath.Join(dir, "docs", "notes.md")
- if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
- t.Fatalf("failed to mkdir: %v", err)
- }
- humanContent := "# Notes\n\nSome human notes.\nAnother line.\n"
- if err := os.WriteFile(humanFile, []byte(humanContent), 0o644); err != nil {
- t.Fatalf("failed to write human file: %v", err)
- }
-
- // Agent creates its own file in a nested directory
- if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil {
- t.Fatalf("failed to mkdir: %v", err)
- }
- agentFile := filepath.Join(dir, "src", "app.go")
- agentContent := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"app\")\n}\n"
- if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil {
- t.Fatalf("failed to write agent file: %v", err)
- }
-
- // Agent stages everything and commits (mid-turn, no SaveStep)
- if _, err := wt.Add("docs/notes.md"); err != nil {
- t.Fatalf("failed to stage: %v", err)
- }
- if _, err := wt.Add("src/app.go"); err != nil {
- t.Fatalf("failed to stage: %v", err)
- }
- _, err = wt.Commit("Add app and notes", &git.CommitOptions{
- Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- // Create live transcript
- transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
- if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
- t.Fatalf("failed to create transcript dir: %v", err)
- }
- transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
- if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"create src/app.go"}}
-{"type":"assistant","message":{"content":"Done"}}
-`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Session state with PromptAttributions capturing human's pre-session file (4 lines)
- state := &SessionState{
- SessionID: "test-mixed-no-shadow",
- BaseCommit: initialHash.String(),
- AttributionBaseCommit: initialHash.String(),
- FilesTouched: []string{"src/app.go"},
- TranscriptPath: transcriptFile,
- AgentType: "Claude Code",
- PromptAttributions: []PromptAttribution{{
- CheckpointNumber: 1,
- UserLinesAdded: 4,
- UserAddedPerFile: map[string]int{"docs/notes.md": 4},
- }},
- }
-
- s := &ManualCommitStrategy{}
- checkpointID := id.MustCheckpointID("d4e5f6a7b8c9")
-
- committedFiles := map[string]struct{}{"src/app.go": {}, "docs/notes.md": {}}
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
- }
-
- // Read metadata
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(sessionMetadataPath)
- if err != nil {
- t.Fatalf("failed to find session metadata at %s: %v", sessionMetadataPath, err)
- }
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata: %v", err)
- }
-
- var metadata struct {
- InitialAttribution *struct {
- AgentLines int `json:"agent_lines"`
- HumanAdded int `json:"human_added"`
- TotalCommitted int `json:"total_committed"`
- AgentPercentage float64 `json:"agent_percentage"`
- } `json:"initial_attribution"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata: %v", err)
- }
-
- if metadata.InitialAttribution == nil {
- t.Fatal("InitialAttribution should be present")
- }
-
- attr := metadata.InitialAttribution
- t.Logf("Attribution (mixed, no shadow): agent=%d, human_added=%d, total=%d, percentage=%.1f%%",
- attr.AgentLines, attr.HumanAdded, attr.TotalCommitted, attr.AgentPercentage)
-
- // src/app.go has 7 lines (agent). docs/notes.md was added before the session
- // (captured by PA1) so it's pre-session baseline — excluded from human count.
- if attr.AgentLines != 7 {
- t.Errorf("AgentLines = %d, want 7 (src/app.go has 7 lines)", attr.AgentLines)
- }
- if attr.HumanAdded != 0 {
- t.Errorf("HumanAdded = %d, want 0 (docs/notes.md is pre-session baseline, excluded)", attr.HumanAdded)
- }
- if attr.TotalCommitted != 7 {
- t.Errorf("TotalCommitted = %d, want 7 (agent-only, pre-session excluded)", attr.TotalCommitted)
- }
- // Agent wrote 7/7 = 100%
- if attr.AgentPercentage < 99.0 {
- t.Errorf("AgentPercentage = %.1f%%, want ~100%% (pre-session human file excluded)", attr.AgentPercentage)
- }
-}
-
-// TestExtractUserPromptsFromLines tests extraction of user prompts from JSONL format.
-func TestExtractUserPromptsFromLines(t *testing.T) {
- tests := []struct {
- name string
- lines []string
- expected []string
- }{
- {
- name: "human type message",
- lines: []string{
- `{"type":"human","message":{"content":"Hello world"}}`,
- },
- expected: []string{"Hello world"},
- },
- {
- name: "user type message",
- lines: []string{
- `{"type":"user","message":{"content":"Test prompt"}}`,
- },
- expected: []string{"Test prompt"},
- },
- {
- name: "mixed human and assistant",
- lines: []string{
- `{"type":"human","message":{"content":"First"}}`,
- `{"type":"assistant","message":{"content":"Response"}}`,
- `{"type":"human","message":{"content":"Second"}}`,
- },
- expected: []string{"First", "Second"},
- },
- {
- name: "array content",
- lines: []string{
- `{"type":"human","message":{"content":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}]}}`,
- },
- expected: []string{"Part 1\n\nPart 2"},
- },
- {
- name: "empty lines ignored",
- lines: []string{
- `{"type":"human","message":{"content":"Valid"}}`,
- "",
- " ",
- },
- expected: []string{"Valid"},
- },
- {
- name: "invalid JSON ignored",
- lines: []string{
- `{"type":"human","message":{"content":"Valid"}}`,
- "not json",
- },
- expected: []string{"Valid"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := extractUserPromptsFromLines(tt.lines)
- if len(result) != len(tt.expected) {
- t.Errorf("extractUserPromptsFromLines() returned %d prompts, want %d", len(result), len(tt.expected))
- return
- }
- for i, prompt := range result {
- if prompt != tt.expected[i] {
- t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i])
- }
- }
- })
- }
-}
-
-// TestMultiCheckpoint_UserEditsBetweenCheckpoints tests that user edits made between
-// agent checkpoints are correctly attributed to the user, not the agent.
-//
-// This tests two scenarios:
-// 1. User edits a DIFFERENT file than agent - detected at checkpoint save time
-// 2. User edits the SAME file as agent - detected at commit time (shadow → head diff)
-//
-//nolint:maintidx // Integration test with multiple steps is inherently complex
-func TestMultiCheckpoint_UserEditsBetweenCheckpoints(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit with two files
- agentFile := filepath.Join(dir, "agent.go")
- userFile := filepath.Join(dir, "user.go")
- if err := os.WriteFile(agentFile, []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write agent file: %v", err)
- }
- if err := os.WriteFile(userFile, []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write user file: %v", err)
- }
- if _, err := worktree.Add("agent.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- if _, err := worktree.Add("user.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-multi-checkpoint-test"
-
- // Create metadata directory
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- transcript := `{"type":"human","message":{"content":"add function"}}
-{"type":"assistant","message":{"content":"adding function"}}
-`
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // === PROMPT 1 START: Initialize session (simulates UserPromptSubmit) ===
- // This must happen BEFORE agent makes any changes
- if err := s.InitializeSession(context.Background(), sessionID, "Claude Code", "", "", ""); err != nil {
- t.Fatalf("InitializeSession() prompt 1 error = %v", err)
- }
-
- // === CHECKPOINT 1: Agent modifies agent.go (adds 4 lines) ===
- checkpoint1Content := "package main\n\nfunc agentFunc1() {\n\tprintln(\"agent1\")\n}\n"
- if err := os.WriteFile(agentFile, []byte(checkpoint1Content), 0o644); err != nil {
- t.Fatalf("failed to write agent changes 1: %v", err)
- }
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"agent.go"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() checkpoint 1 error = %v", err)
- }
-
- // Verify PromptAttribution was recorded for checkpoint 1
- state1, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() after checkpoint 1 error = %v", err)
- }
- if len(state1.PromptAttributions) != 1 {
- t.Fatalf("expected 1 PromptAttribution after checkpoint 1, got %d", len(state1.PromptAttributions))
- }
- // First checkpoint: no user edits yet (user.go hasn't changed)
- if state1.PromptAttributions[0].UserLinesAdded != 0 {
- t.Errorf("checkpoint 1: expected 0 user lines added, got %d", state1.PromptAttributions[0].UserLinesAdded)
- }
-
- // === USER EDITS A DIFFERENT FILE (user.go) BETWEEN CHECKPOINTS ===
- userEditContent := "package main\n\n// User added this function\nfunc userFunc() {\n\tprintln(\"user\")\n}\n"
- if err := os.WriteFile(userFile, []byte(userEditContent), 0o644); err != nil {
- t.Fatalf("failed to write user edits: %v", err)
- }
-
- // === PROMPT 2 START: Initialize session again (simulates UserPromptSubmit) ===
- // This captures the user's edits to user.go BEFORE the agent runs
- if err := s.InitializeSession(context.Background(), sessionID, "Claude Code", "", "", ""); err != nil {
- t.Fatalf("InitializeSession() prompt 2 error = %v", err)
- }
-
- // === CHECKPOINT 2: Agent modifies agent.go again (adds 4 more lines) ===
- checkpoint2Content := "package main\n\nfunc agentFunc1() {\n\tprintln(\"agent1\")\n}\n\nfunc agentFunc2() {\n\tprintln(\"agent2\")\n}\n"
- if err := os.WriteFile(agentFile, []byte(checkpoint2Content), 0o644); err != nil {
- t.Fatalf("failed to write agent changes 2: %v", err)
- }
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"agent.go"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() checkpoint 2 error = %v", err)
- }
-
- // Verify PromptAttribution was recorded for checkpoint 2
- state2, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() after checkpoint 2 error = %v", err)
- }
- if len(state2.PromptAttributions) != 2 {
- t.Fatalf("expected 2 PromptAttributions after checkpoint 2, got %d", len(state2.PromptAttributions))
- }
-
- t.Logf("Checkpoint 2 PromptAttribution: user_added=%d, user_removed=%d, agent_added=%d, agent_removed=%d",
- state2.PromptAttributions[1].UserLinesAdded,
- state2.PromptAttributions[1].UserLinesRemoved,
- state2.PromptAttributions[1].AgentLinesAdded,
- state2.PromptAttributions[1].AgentLinesRemoved)
-
- // Second checkpoint should detect user's edits to user.go (different file than agent)
- // User added 5 lines to user.go
- if state2.PromptAttributions[1].UserLinesAdded == 0 {
- t.Error("checkpoint 2: expected user lines added > 0 because user edited user.go")
- }
-
- // === USER COMMITS ===
- if _, err := worktree.Add("agent.go"); err != nil {
- t.Fatalf("failed to stage agent.go: %v", err)
- }
- if _, err := worktree.Add("user.go"); err != nil {
- t.Fatalf("failed to stage user.go: %v", err)
- }
- _, err = worktree.Commit("Final commit with agent and user changes", &git.CommitOptions{
- Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- // === CONDENSE AND VERIFY ATTRIBUTION ===
- checkpointID := id.MustCheckpointID("b2c3d4e5f6a7")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state2, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %q, want %q", result.CheckpointID, checkpointID)
- }
-
- // Read metadata and verify attribution
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
-
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
-
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- // InitialAttribution is stored in session-level metadata (0/metadata.json), not root (0-based indexing)
- sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(sessionMetadataPath)
- if err != nil {
- t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
- }
-
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata.json: %v", err)
- }
-
- var metadata struct {
- InitialAttribution *struct {
- AgentLines int `json:"agent_lines"`
- HumanAdded int `json:"human_added"`
- HumanModified int `json:"human_modified"`
- HumanRemoved int `json:"human_removed"`
- TotalCommitted int `json:"total_committed"`
- AgentPercentage float64 `json:"agent_percentage"`
- } `json:"initial_attribution"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata.json: %v", err)
- }
-
- if metadata.InitialAttribution == nil {
- t.Fatal("InitialAttribution should be present in session metadata")
- }
-
- t.Logf("Final Attribution: agent=%d, human_added=%d, human_modified=%d, human_removed=%d, total=%d, percentage=%.1f%%",
- metadata.InitialAttribution.AgentLines,
- metadata.InitialAttribution.HumanAdded,
- metadata.InitialAttribution.HumanModified,
- metadata.InitialAttribution.HumanRemoved,
- metadata.InitialAttribution.TotalCommitted,
- metadata.InitialAttribution.AgentPercentage)
-
- // Verify the attribution makes sense:
- // - Agent modified agent.go: added ~8 lines total
- // - User modified user.go: added ~5 lines
- // - So agent percentage should be around 50-70%
- if metadata.InitialAttribution.AgentLines == 0 {
- t.Error("AgentLines should be > 0")
- }
- if metadata.InitialAttribution.TotalCommitted == 0 {
- t.Error("TotalCommitted should be > 0")
- }
-
- // The key test: user's lines should be captured in HumanAdded
- if metadata.InitialAttribution.HumanAdded == 0 {
- t.Error("HumanAdded should be > 0 because user added lines to user.go")
- }
-
- // Agent percentage should not be 100% since user contributed
- if metadata.InitialAttribution.AgentPercentage >= 100 {
- t.Errorf("AgentPercentage should be < 100%% since user contributed, got %.1f%%",
- metadata.InitialAttribution.AgentPercentage)
- }
-}
-
-// TestCondenseSession_PrefersLiveTranscript verifies that CondenseSession reads the
-// live transcript file when available, rather than the potentially stale shadow branch copy.
-// This reproduces the bug where SaveStep was skipped (no code changes) but the
-// transcript continued growing — deferred condensation would read stale data.
-func TestCondenseSession_PrefersLiveTranscript(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- // Create initial commit
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := wt.Add("file.txt"); err != nil {
- t.Fatalf("failed to stage: %v", err)
- }
- _, err = wt.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-live-transcript"
-
- // Create metadata dir with an initial (short) transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- staleTranscript := `{"type":"human","message":{"content":"first prompt"}}
-{"type":"assistant","message":{"content":"first response"}}
-`
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(staleTranscript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // SaveStep to create shadow branch with the stale transcript
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Now simulate the conversation continuing: write a LONGER live transcript file.
- // In the real bug, SaveStep would be skipped because totalChanges == 0,
- // so the shadow branch still has the stale version.
- liveTranscriptFile := filepath.Join(dir, "live-transcript.jsonl")
- liveTranscript := `{"type":"human","message":{"content":"first prompt"}}
-{"type":"assistant","message":{"content":"first response"}}
-{"type":"human","message":{"content":"second prompt"}}
-{"type":"assistant","message":{"content":"second response"}}
-`
- if err := os.WriteFile(liveTranscriptFile, []byte(liveTranscript), 0o644); err != nil {
- t.Fatalf("failed to write live transcript: %v", err)
- }
-
- // Load session state and set TranscriptPath to the live file
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- state.TranscriptPath = liveTranscriptFile
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- // Condense — this should read the live transcript, not the shadow branch copy
- checkpointID := id.MustCheckpointID("b2c3d4e5f6a1")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // The live transcript has 4 lines; the shadow branch copy has 2.
- // If we read the stale shadow copy, we'd only see 2 lines.
- if result.TotalTranscriptLines != 4 {
- t.Errorf("TotalTranscriptLines = %d, want 4 (live transcript has 4 lines, shadow has 2)", result.TotalTranscriptLines)
- }
-
- // Verify the condensed content includes the second prompt
- store := checkpoint.NewGitStore(repo)
- content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
- if err != nil {
- t.Fatalf("ReadLatestSessionContent() error = %v", err)
- }
- if !strings.Contains(string(content.Transcript), "second prompt") {
- t.Error("condensed transcript should contain 'second prompt' from live file, but it doesn't")
- }
-}
-
-// TestCondenseSession_TranscriptRelocatedMidSession verifies that CondenseSession
-// succeeds when the agent relocates its transcript mid-session (e.g., Cursor CLI
-// switching from flat /.jsonl to nested //.jsonl layout).
-// This is a regression test for a Cursor CLI 2026.03.11 change that broke mid-turn
-// commits because the stored TranscriptPath became stale.
-func TestCondenseSession_TranscriptRelocatedMidSession(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- wt, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
- if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := wt.Add("file.txt"); err != nil {
- t.Fatalf("failed to stage: %v", err)
- }
- _, err = wt.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "87874108-eff2-47a0-b260-183961dd6cb0"
-
- // Create the session state with a flat TranscriptPath (what before-submit-prompt reports)
- agentTranscriptsDir := filepath.Join(dir, "agent-transcripts")
- if err := os.MkdirAll(agentTranscriptsDir, 0o755); err != nil {
- t.Fatalf("failed to create agent-transcripts dir: %v", err)
- }
- flatPath := filepath.Join(agentTranscriptsDir, sessionID+".jsonl")
-
- // But the file actually lives at the nested path (Cursor relocated it)
- nestedDir := filepath.Join(agentTranscriptsDir, sessionID)
- if err := os.MkdirAll(nestedDir, 0o755); err != nil {
- t.Fatalf("failed to create nested dir: %v", err)
- }
- nestedPath := filepath.Join(nestedDir, sessionID+".jsonl")
- transcript := `{"type":"human","message":{"content":"create a file"}}
-{"type":"assistant","message":{"content":"done"}}
-`
- if err := os.WriteFile(nestedPath, []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Create session state pointing to the FLAT (stale) path
- head, err := repo.Head()
- if err != nil {
- t.Fatalf("failed to get HEAD: %v", err)
- }
- state := &SessionState{
- SessionID: sessionID,
- BaseCommit: head.Hash().String(),
- WorktreePath: dir,
- AgentType: agent.AgentTypeCursor,
- TranscriptPath: flatPath, // stale: file was relocated to nested path
- }
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("saveSessionState() error = %v", err)
- }
-
- // CondenseSession should succeed by re-resolving the transcript path
- checkpointID := id.MustCheckpointID("c1d2e3f4a5b6")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v, want nil (should re-resolve stale transcript path)", err)
- }
-
- if result.TotalTranscriptLines != 2 {
- t.Errorf("TotalTranscriptLines = %d, want 2", result.TotalTranscriptLines)
- }
-
- // State should have been updated to the resolved path
- if state.TranscriptPath != nestedPath {
- t.Errorf("state.TranscriptPath = %q, want %q (should be updated after re-resolution)", state.TranscriptPath, nestedPath)
- }
-}
-
-// TestCondenseSession_GeminiTranscript verifies that CondenseSession works correctly
-// with Gemini JSON format transcripts, including prompt extraction and format detection.
-func TestCondenseSession_GeminiTranscript(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(dir, "test.txt")
- if err := os.WriteFile(testFile, []byte("initial content"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("test.txt"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2026-02-09-gemini-test"
-
- // Create metadata directory with Gemini JSON transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- // Gemini JSON format with IDE tags to test stripping
- geminiTranscript := `{
- "sessionId": "test-session",
- "messages": [
- {
- "type": "user",
- "content": "test.txtCreate a new file"
- },
- {
- "type": "gemini",
- "content": "I'll create the file for you",
- "tokens": {
- "input": 50,
- "output": 20,
- "cached": 10
- }
- }
- ]
- }`
-
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(geminiTranscript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Write prompt.txt (simulating what lifecycle does at turn start / turn end)
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Create a new file"), 0o644); err != nil {
- t.Fatalf("failed to write prompt file: %v", err)
- }
-
- // Create modified file
- if err := os.WriteFile(testFile, []byte("modified by gemini"), 0o644); err != nil {
- t.Fatalf("failed to modify file: %v", err)
- }
-
- // Save checkpoint (creates shadow branch)
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.txt"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Gemini CLI",
- AuthorEmail: "gemini@test.com",
- AgentType: agent.AgentTypeGemini,
- })
- if err != nil {
- t.Fatalf("SaveStep() error = %v", err)
- }
-
- // Load session state
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if state.AgentType != agent.AgentTypeGemini {
- t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini)
- }
-
- // Condense the session
- checkpointID := id.MustCheckpointID("aabbcc112233")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Verify result
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %v, want %v", result.CheckpointID, checkpointID)
- }
- if result.SessionID != sessionID {
- t.Errorf("SessionID = %q, want %q", result.SessionID, sessionID)
- }
- if len(result.FilesTouched) != 1 || result.FilesTouched[0] != "test.txt" {
- t.Errorf("FilesTouched = %v, want [test.txt]", result.FilesTouched)
- }
-
- // Verify condensed data on trace/checkpoints/v1 branch
- store := checkpoint.NewGitStore(repo)
- content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
- if err != nil {
- t.Fatalf("ReadLatestSessionContent() error = %v", err)
- }
-
- // Verify transcript was stored
- if len(content.Transcript) == 0 {
- t.Error("Transcript should not be empty")
- }
-
- // Verify prompts were extracted and IDE tags were stripped
- if !strings.Contains(content.Prompts, "Create a new file") {
- t.Errorf("Prompts = %q, should contain %q (IDE tags should be stripped)", content.Prompts, "Create a new file")
- }
- if strings.Contains(content.Prompts, "") {
- t.Error("Prompts should not contain IDE tags")
- }
-
- // Verify token usage was calculated
- if content.Metadata.TokenUsage == nil {
- t.Fatal("TokenUsage should not be nil for Gemini transcript")
- }
- if content.Metadata.TokenUsage.InputTokens != 50 {
- t.Errorf("InputTokens = %d, want 50", content.Metadata.TokenUsage.InputTokens)
- }
- if content.Metadata.TokenUsage.OutputTokens != 20 {
- t.Errorf("OutputTokens = %d, want 20", content.Metadata.TokenUsage.OutputTokens)
- }
- if content.Metadata.TokenUsage.CacheReadTokens != 10 {
- t.Errorf("CacheReadTokens = %d, want 10", content.Metadata.TokenUsage.CacheReadTokens)
- }
-}
-
-// TestCondenseSession_GeminiMultiCheckpoint verifies that multi-checkpoint Gemini sessions
-// correctly scope token usage to only the checkpoint portion (not the trace transcript).
-// This is the core bug fix - ensuring CheckpointTranscriptStart is properly used.
-//
-//nolint:maintidx // Integration test with comprehensive verification steps
-func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(dir, "code.go")
- if err := os.WriteFile(testFile, []byte("package main"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("code.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- _, err = worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- if err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2026-02-09-multi-checkpoint"
-
- // Create metadata directory
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil {
- t.Fatalf("failed to create metadata dir: %v", err)
- }
-
- transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
-
- // CHECKPOINT 1: Initial work with 2 messages (1 gemini message with tokens)
- checkpoint1Transcript := `{
- "sessionId": "multi-test",
- "messages": [
- {
- "type": "user",
- "content": "Add a main function"
- },
- {
- "type": "gemini",
- "content": "I'll add a main function",
- "tokens": {
- "input": 100,
- "output": 50,
- "cached": 20
- }
- }
- ]
- }`
-
- if err := os.WriteFile(transcriptPath, []byte(checkpoint1Transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Write prompt.txt for checkpoint 1 (simulating what lifecycle does)
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Add a main function"), 0o644); err != nil {
- t.Fatalf("failed to write prompt file: %v", err)
- }
-
- // Modify file for checkpoint 1
- if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
- t.Fatalf("failed to modify file: %v", err)
- }
-
- // Save checkpoint 1
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"code.go"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Gemini CLI",
- AuthorEmail: "gemini@test.com",
- AgentType: agent.AgentTypeGemini,
- })
- if err != nil {
- t.Fatalf("SaveStep() checkpoint 1 error = %v", err)
- }
-
- // Load and verify state after checkpoint 1
- state, err := s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
- if state.CheckpointTranscriptStart != 0 {
- t.Errorf("CheckpointTranscriptStart after checkpoint 1 = %d, want 0", state.CheckpointTranscriptStart)
- }
-
- // CHECKPOINT 2: Add more messages to transcript (simulating continued session)
- // This adds 2 more messages (indices 2 and 3), with new token counts
- checkpoint2Transcript := `{
- "sessionId": "multi-test",
- "messages": [
- {
- "type": "user",
- "content": "Add a main function"
- },
- {
- "type": "gemini",
- "content": "I'll add a main function",
- "tokens": {
- "input": 100,
- "output": 50,
- "cached": 20
- }
- },
- {
- "type": "user",
- "content": "Now add error handling"
- },
- {
- "type": "gemini",
- "content": "I'll add error handling",
- "tokens": {
- "input": 200,
- "output": 75,
- "cached": 30
- }
- }
- ]
- }`
-
- if err := os.WriteFile(transcriptPath, []byte(checkpoint2Transcript), 0o644); err != nil {
- t.Fatalf("failed to update transcript: %v", err)
- }
-
- // Simulate condensation clearing prompt.txt (condenseAndUpdateState does this),
- // then lifecycle appending the new prompt at turn start.
- if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.PromptFileName), []byte("Now add error handling"), 0o644); err != nil {
- t.Fatalf("failed to write prompt file: %v", err)
- }
-
- // Modify file for checkpoint 2
- if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tpanic(err)\n\t}\n}\n"), 0o644); err != nil {
- t.Fatalf("failed to modify file: %v", err)
- }
-
- // Before checkpoint 2, manually update CheckpointTranscriptStart to simulate
- // what would happen after condensing checkpoint 1
- state.CheckpointTranscriptStart = 2 // Start from message index 2 (the second user prompt)
- state.StepCount = 1 // Set to 1 (will be incremented to 2 by SaveStep)
- if err := s.saveSessionState(context.Background(), state); err != nil {
- t.Fatalf("failed to update session state: %v", err)
- }
-
- // Save checkpoint 2
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"code.go"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Gemini CLI",
- AuthorEmail: "gemini@test.com",
- AgentType: agent.AgentTypeGemini,
- })
- if err != nil {
- t.Fatalf("SaveStep() checkpoint 2 error = %v", err)
- }
-
- // Reload state to get updated values
- state, err = s.loadSessionState(context.Background(), sessionID)
- if err != nil {
- t.Fatalf("loadSessionState() error = %v", err)
- }
-
- // Condense the session - this should calculate token usage ONLY from message index 2 onwards
- checkpointID := id.MustCheckpointID("ddeeff998877")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Verify result
- if result.CheckpointsCount != 2 {
- t.Errorf("CheckpointsCount = %d, want 2", result.CheckpointsCount)
- }
- if result.TotalTranscriptLines != 4 {
- t.Errorf("TotalTranscriptLines = %d, want 4 (4 messages in Gemini format)", result.TotalTranscriptLines)
- }
-
- // Read condensed metadata
- store := checkpoint.NewGitStore(repo)
- content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
- if err != nil {
- t.Fatalf("ReadLatestSessionContent() error = %v", err)
- }
-
- // CRITICAL VERIFICATION: Token usage should ONLY count from message index 2 onwards
- // This means ONLY the second gemini message (indices 2-3), NOT the first one (indices 0-1)
- if content.Metadata.TokenUsage == nil {
- t.Fatal("TokenUsage should not be nil")
- }
-
- // Expected: Only the second gemini message tokens (input=200, output=75, cached=30)
- // NOT the first gemini message tokens (input=100, output=50, cached=20)
- if content.Metadata.TokenUsage.InputTokens != 200 {
- t.Errorf("InputTokens = %d, want 200 (should only count from checkpoint start, not trace transcript)",
- content.Metadata.TokenUsage.InputTokens)
- }
- if content.Metadata.TokenUsage.OutputTokens != 75 {
- t.Errorf("OutputTokens = %d, want 75 (should only count from checkpoint start, not trace transcript)",
- content.Metadata.TokenUsage.OutputTokens)
- }
- if content.Metadata.TokenUsage.CacheReadTokens != 30 {
- t.Errorf("CacheReadTokens = %d, want 30 (should only count from checkpoint start, not trace transcript)",
- content.Metadata.TokenUsage.CacheReadTokens)
- }
- if content.Metadata.TokenUsage.APICallCount != 1 {
- t.Errorf("APICallCount = %d, want 1 (only one gemini message after checkpoint start)",
- content.Metadata.TokenUsage.APICallCount)
- }
-
- // Verify the full transcript is stored (all 4 messages)
- if len(content.Transcript) == 0 {
- t.Error("Full transcript should be stored")
- }
-
- // Verify only checkpoint-scoped prompts are present (from CheckpointTranscriptStart onwards)
- if strings.Contains(content.Prompts, "Add a main function") {
- t.Error("Prompts should NOT contain first prompt (before checkpoint start)")
- }
- if !strings.Contains(content.Prompts, "Now add error handling") {
- t.Error("Prompts should contain second prompt (checkpoint-scoped)")
- }
-}
-
-func TestCondenseSession_CopilotScopedCheckpointMetadataAndSessionBackfill(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- AllowEmptyCommits: true,
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- t.Chdir(dir)
-
- sessionID := "2026-03-17-copilot-token-scope"
- transcriptDir := filepath.Join(dir, ".copilot", "session-state", sessionID)
- if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
- t.Fatalf("failed to create transcript dir: %v", err)
- }
- transcriptPath := filepath.Join(transcriptDir, "events.jsonl")
-
- transcript := strings.Join([]string{
- `{"type":"session.start","data":{"sessionId":"2026-03-17-copilot-token-scope"},"id":"1","timestamp":"2026-03-17T00:00:00Z","parentId":""}`,
- `{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"id":"2","timestamp":"2026-03-17T00:00:01Z","parentId":"1"}`,
- `{"type":"user.message","data":{"content":"Create alpha.txt"},"id":"3","timestamp":"2026-03-17T00:00:02Z","parentId":""}`,
- `{"type":"assistant.message","data":{"content":"Created alpha.txt","outputTokens":10},"id":"4","timestamp":"2026-03-17T00:00:03Z","parentId":"3"}`,
- `{"type":"tool.execution_complete","data":{"toolCallId":"tool-1","model":"claude-sonnet-4.6","toolTelemetry":{"properties":{"filePaths":"[\"alpha.txt\"]"},"metrics":{"linesAdded":1,"linesRemoved":0}}},"id":"5","timestamp":"2026-03-17T00:00:04Z","parentId":"4"}`,
- `{"type":"user.message","data":{"content":"Create beta.txt"},"id":"6","timestamp":"2026-03-17T00:00:05Z","parentId":""}`,
- `{"type":"assistant.message","data":{"content":"Created beta.txt","outputTokens":25},"id":"7","timestamp":"2026-03-17T00:00:06Z","parentId":"6"}`,
- `{"type":"tool.execution_complete","data":{"toolCallId":"tool-2","model":"claude-sonnet-4.6","toolTelemetry":{"properties":{"filePaths":"[\"beta.txt\"]"},"metrics":{"linesAdded":1,"linesRemoved":0}}},"id":"8","timestamp":"2026-03-17T00:00:07Z","parentId":"7"}`,
- `{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"requests":{"count":2},"usage":{"inputTokens":0,"outputTokens":35,"cacheReadTokens":20,"cacheWriteTokens":10}}}},"id":"9","timestamp":"2026-03-17T00:00:08Z","parentId":""}`,
- }, "\n") + "\n"
- if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- state := &SessionState{
- SessionID: sessionID,
- BaseCommit: initialHash.String(),
- StartedAt: time.Now(),
- FilesTouched: []string{"beta.txt"},
- WorktreePath: dir,
- TranscriptPath: transcriptPath,
- AgentType: agent.AgentTypeCopilotCLI,
- ModelName: "claude-sonnet-4.6",
- CheckpointTranscriptStart: 5,
- }
-
- s := &ManualCommitStrategy{}
- checkpointID := id.MustCheckpointID("cc11aa22bb33")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- if result.CheckpointID != checkpointID {
- t.Errorf("CheckpointID = %v, want %v", result.CheckpointID, checkpointID)
- }
- if len(result.FilesTouched) != 1 || result.FilesTouched[0] != "beta.txt" {
- t.Errorf("FilesTouched = %v, want [beta.txt]", result.FilesTouched)
- }
-
- store := checkpoint.NewGitStore(repo)
- content, err := store.ReadLatestSessionContent(t.Context(), checkpointID)
- if err != nil {
- t.Fatalf("ReadLatestSessionContent() error = %v", err)
- }
-
- if content.Metadata.TokenUsage == nil {
- t.Fatal("TokenUsage should not be nil")
- }
- if content.Metadata.TokenUsage.InputTokens != 0 {
- t.Errorf("metadata InputTokens = %d, want 0 for scoped Copilot checkpoint usage", content.Metadata.TokenUsage.InputTokens)
- }
- if content.Metadata.TokenUsage.OutputTokens != 25 {
- t.Errorf("metadata OutputTokens = %d, want 25 for second checkpoint assistant output", content.Metadata.TokenUsage.OutputTokens)
- }
- if content.Metadata.TokenUsage.CacheReadTokens != 0 {
- t.Errorf("metadata CacheReadTokens = %d, want 0 for scoped fallback path", content.Metadata.TokenUsage.CacheReadTokens)
- }
- if content.Metadata.TokenUsage.CacheCreationTokens != 0 {
- t.Errorf("metadata CacheCreationTokens = %d, want 0 for scoped fallback path", content.Metadata.TokenUsage.CacheCreationTokens)
- }
- if content.Metadata.TokenUsage.APICallCount != 1 {
- t.Errorf("metadata APICallCount = %d, want 1", content.Metadata.TokenUsage.APICallCount)
- }
-
- if state.TokenUsage == nil {
- t.Fatal("state.TokenUsage should not be nil after Copilot session backfill")
- }
- if state.TokenUsage.InputTokens != 0 {
- t.Errorf("state InputTokens = %d, want 0 from session.shutdown", state.TokenUsage.InputTokens)
- }
- if state.TokenUsage.OutputTokens != 35 {
- t.Errorf("state OutputTokens = %d, want 35 from session.shutdown", state.TokenUsage.OutputTokens)
- }
- if state.TokenUsage.CacheReadTokens != 20 {
- t.Errorf("state CacheReadTokens = %d, want 20 from session.shutdown", state.TokenUsage.CacheReadTokens)
- }
- if state.TokenUsage.CacheCreationTokens != 10 {
- t.Errorf("state CacheCreationTokens = %d, want 10 from session.shutdown", state.TokenUsage.CacheCreationTokens)
- }
- if state.TokenUsage.APICallCount != 2 {
- t.Errorf("state APICallCount = %d, want 2 from session.shutdown", state.TokenUsage.APICallCount)
- }
-}
-
-// TestCondenseSession_FilesTouchedFallback_EmptyState verifies that when state.FilesTouched
-// is empty (mid-session commit before SaveStep), the fallback to committedFiles works.
-// This is the legitimate use case for the fallback.
-func TestCondenseSession_FilesTouchedFallback_EmptyState(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- AllowEmptyCommits: true,
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create a file and commit it (simulating agent mid-turn commit)
- agentFile := filepath.Join(dir, "agent.go")
- if err := os.WriteFile(agentFile, []byte("package main\n"), 0o644); err != nil {
- t.Fatalf("failed to write file: %v", err)
- }
- if _, err := worktree.Add("agent.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- if _, err = worktree.Commit("Add agent.go", &git.CommitOptions{
- Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- // Create live transcript (required when no shadow branch)
- transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
- if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
- t.Fatalf("failed to create transcript dir: %v", err)
- }
- transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
- if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"create agent.go"}}
-{"type":"assistant","message":{"content":"Done"}}
-`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Session state with EMPTY FilesTouched (mid-session commit scenario)
- state := &SessionState{
- SessionID: "test-empty-files",
- BaseCommit: initialHash.String(),
- FilesTouched: []string{}, // Empty - no SaveStep called yet
- TranscriptPath: transcriptFile,
- AgentType: "Claude Code",
- }
-
- s := &ManualCommitStrategy{}
- checkpointID := id.MustCheckpointID("fa11bac00001")
-
- // Condense with committedFiles - should fallback since FilesTouched is empty
- committedFiles := map[string]struct{}{"agent.go": {}}
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Read metadata and verify files_touched contains the committed file (fallback worked)
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(metadataPath)
- if err != nil {
- t.Fatalf("failed to find metadata: %v", err)
- }
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata: %v", err)
- }
-
- var metadata struct {
- FilesTouched []string `json:"files_touched"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata: %v", err)
- }
-
- // Verify fallback worked - files_touched should contain agent.go
- if len(metadata.FilesTouched) != 1 || metadata.FilesTouched[0] != "agent.go" {
- t.Errorf("files_touched = %v, want [agent.go] (fallback should apply when FilesTouched is empty)",
- metadata.FilesTouched)
- }
-
- t.Logf("Fallback worked: files_touched = %v, result = %+v", metadata.FilesTouched, result)
-}
-
-// TestCondenseSession_FilesTouchedNoFallback_NoOverlap verifies that when state.FilesTouched
-// has files but none overlap with committedFiles, we do NOT fallback to committedFiles.
-// This prevents the bug where unrelated sessions get incorrect files_touched.
-func TestCondenseSession_FilesTouchedNoFallback_NoOverlap(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- if err != nil {
- t.Fatalf("failed to init repo: %v", err)
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- t.Fatalf("failed to get worktree: %v", err)
- }
-
- // Create initial commit
- initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- AllowEmptyCommits: true,
- })
- if err != nil {
- t.Fatalf("failed to create initial commit: %v", err)
- }
-
- // Create files for both the session's work and the committed file
- sessionFile := filepath.Join(dir, "session_file.go")
- if err := os.WriteFile(sessionFile, []byte("package session\n"), 0o644); err != nil {
- t.Fatalf("failed to write session file: %v", err)
- }
- committedFile := filepath.Join(dir, "other_file.go")
- if err := os.WriteFile(committedFile, []byte("package other\n"), 0o644); err != nil {
- t.Fatalf("failed to write committed file: %v", err)
- }
-
- // Only commit the "other" file (not the session's file)
- if _, err := worktree.Add("other_file.go"); err != nil {
- t.Fatalf("failed to stage file: %v", err)
- }
- if _, err = worktree.Commit("Add other_file.go", &git.CommitOptions{
- Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()},
- }); err != nil {
- t.Fatalf("failed to commit: %v", err)
- }
-
- t.Chdir(dir)
-
- // Create live transcript
- transcriptDir := filepath.Join(dir, ".claude", "projects", "test")
- if err := os.MkdirAll(transcriptDir, 0o755); err != nil {
- t.Fatalf("failed to create transcript dir: %v", err)
- }
- transcriptFile := filepath.Join(transcriptDir, "session.jsonl")
- if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"work on session_file.go"}}
-{"type":"assistant","message":{"content":"Done"}}
-`), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Session state with FilesTouched that does NOT overlap with committedFiles
- state := &SessionState{
- SessionID: "test-no-overlap",
- BaseCommit: initialHash.String(),
- FilesTouched: []string{"session_file.go"}, // Does NOT overlap with other_file.go
- TranscriptPath: transcriptFile,
- AgentType: "Claude Code",
- }
-
- s := &ManualCommitStrategy{}
- checkpointID := id.MustCheckpointID("00001a000001")
-
- // Condense with committedFiles that don't overlap
- committedFiles := map[string]struct{}{"other_file.go": {}}
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, committedFiles)
- if err != nil {
- t.Fatalf("CondenseSession() error = %v", err)
- }
-
- // Read metadata and verify files_touched is EMPTY (no fallback applied)
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- if err != nil {
- t.Fatalf("failed to get sessions branch: %v", err)
- }
- sessionsCommit, err := repo.CommitObject(sessionsRef.Hash())
- if err != nil {
- t.Fatalf("failed to get sessions commit: %v", err)
- }
- tree, err := sessionsCommit.Tree()
- if err != nil {
- t.Fatalf("failed to get tree: %v", err)
- }
-
- metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
- metadataFile, err := tree.File(metadataPath)
- if err != nil {
- t.Fatalf("failed to find metadata: %v", err)
- }
- content, err := metadataFile.Contents()
- if err != nil {
- t.Fatalf("failed to read metadata: %v", err)
- }
-
- var metadata struct {
- FilesTouched []string `json:"files_touched"`
- }
- if err := json.Unmarshal([]byte(content), &metadata); err != nil {
- t.Fatalf("failed to parse metadata: %v", err)
- }
-
- // Verify NO fallback - files_touched should be EMPTY, NOT contain other_file.go
- // This is the key fix: session had files (session_file.go) but none overlapped,
- // so we should NOT fallback to committedFiles (other_file.go)
- if len(metadata.FilesTouched) != 0 {
- t.Errorf("files_touched = %v, want [] (should NOT fallback when session had files but no overlap)",
- metadata.FilesTouched)
- }
-
- t.Logf("No fallback applied: files_touched = %v (correctly empty), result = %+v", metadata.FilesTouched, result)
-}
-
-// TestExtractFilesFromLiveTranscript_RespectsOffset verifies that after condensation
-// sets CheckpointTranscriptStart = N, resolveFilesTouched only returns
-// files from messages at index N and beyond, not from the beginning.
-//
-// This is a regression test for a bug where compaction events (pre-compress hooks)
-// unconditionally reset CheckpointTranscriptStart to 0, causing already-condensed
-// files to re-appear in carry-forward and break sequential commit scenarios.
-func TestExtractFilesFromLiveTranscript_RespectsOffset(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- // Create a Gemini-format transcript with 3 file writes at different message indices:
- // msg 0: user prompt
- // msg 1: gemini writes red.md (already condensed)
- // msg 2: user prompt
- // msg 3: gemini writes blue.md (already condensed)
- // msg 4: user prompt
- // msg 5: gemini writes green.md (new, should be extracted)
- transcript := `{
- "messages": [
- {"type": "user", "content": [{"text": "create red.md"}]},
- {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/red.md"}}]},
- {"type": "user", "content": [{"text": "create blue.md"}]},
- {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/blue.md"}}]},
- {"type": "user", "content": [{"text": "create green.md"}]},
- {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "docs/green.md"}}]}
- ]
-}`
-
- transcriptPath := filepath.Join(dir, "transcript.json")
- if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- // Simulate state after 2 condensations: offset points past blue.md's message
- state := &SessionState{
- SessionID: "test-offset-session",
- TranscriptPath: transcriptPath,
- AgentType: agent.AgentTypeGemini,
- WorktreePath: dir,
- CheckpointTranscriptStart: 4, // Past red.md (msg 1) and blue.md (msg 3)
- }
-
- // With correct offset (4): should only find green.md
- files := s.resolveFilesTouched(context.Background(), state)
- if len(files) != 1 || files[0] != "docs/green.md" {
- t.Errorf("resolveFilesTouched(offset=4) = %v, want [docs/green.md]", files)
- }
-
- // With reset offset (0): would incorrectly find all 3 files (the bug)
- state.CheckpointTranscriptStart = 0
- allFiles := s.resolveFilesTouched(context.Background(), state)
- if len(allFiles) != 3 {
- t.Errorf("resolveFilesTouched(offset=0) got %d files, want 3: %v", len(allFiles), allFiles)
- }
-}
-
-// TestResolveFilesTouched_PrefersStateFallsBackToTranscript verifies the two-tier
-// resolution in resolveFilesTouched: state.FilesTouched is preferred (returns a copy),
-// and transcript extraction is only used as a fallback when FilesTouched is empty.
-func TestResolveFilesTouched_PrefersStateFallsBackToTranscript(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
-
- // Gemini transcript containing a file write
- transcript := `{
- "messages": [
- {"type": "user", "content": [{"text": "create file"}]},
- {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "from-transcript.txt"}}]}
- ]
-}`
- transcriptPath := filepath.Join(dir, "transcript.json")
- if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
- t.Fatalf("failed to write transcript: %v", err)
- }
-
- t.Run("prefers FilesTouched over transcript", func(t *testing.T) {
- state := &SessionState{
- SessionID: "test-prefers-state",
- TranscriptPath: transcriptPath,
- AgentType: agent.AgentTypeGemini,
- WorktreePath: dir,
- FilesTouched: []string{"from-hook.txt"},
- }
- files := s.resolveFilesTouched(context.Background(), state)
- if len(files) != 1 || files[0] != "from-hook.txt" {
- t.Errorf("resolveFilesTouched with FilesTouched = %v, want [from-hook.txt]", files)
- }
- })
-
- t.Run("returns copy of FilesTouched", func(t *testing.T) {
- state := &SessionState{
- SessionID: "test-copy",
- FilesTouched: []string{"a.txt", "b.txt"},
- }
- files := s.resolveFilesTouched(context.Background(), state)
- // Mutating returned slice should not affect state
- files[0] = "mutated.txt"
- if state.FilesTouched[0] != "a.txt" {
- t.Errorf("resolveFilesTouched did not return a copy; state.FilesTouched[0] = %q", state.FilesTouched[0])
- }
- })
-
- t.Run("falls back to transcript when FilesTouched is empty", func(t *testing.T) {
- state := &SessionState{
- SessionID: "test-fallback",
- TranscriptPath: transcriptPath,
- AgentType: agent.AgentTypeGemini,
- WorktreePath: dir,
- FilesTouched: nil,
- }
- files := s.resolveFilesTouched(context.Background(), state)
- if len(files) != 1 || files[0] != "from-transcript.txt" {
- t.Errorf("resolveFilesTouched with empty FilesTouched = %v, want [from-transcript.txt]", files)
- }
- })
-
- t.Run("returns nil when both sources are empty", func(t *testing.T) {
- state := &SessionState{
- SessionID: "test-empty",
- FilesTouched: nil,
- // No transcript path — extraction will return nil
- }
- files := s.resolveFilesTouched(context.Background(), state)
- if files != nil {
- t.Errorf("resolveFilesTouched with no sources = %v, want nil", files)
- }
- })
-}
-
-// TestCondenseSession_V2DualWrite verifies that when checkpoints_v2 is enabled,
-// CondenseSession writes to both v1 (trace/checkpoints/v1) and v2 refs
-// (refs/trace/checkpoints/v2/main and refs/trace/checkpoints/v2/full/current).
-func TestCondenseSession_V2DualWrite(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644))
- _, err = worktree.Add("main.go")
- require.NoError(t, err)
- commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- t.Chdir(dir)
-
- // Enable checkpoints_v2 via settings
- traceDir := filepath.Join(dir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-v2-dual-write"
-
- // Create metadata directory with transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- secret := "q9Xv2Lm8Rt1Yp4Kd7Wz0Hs6Nc3Bf5Jg"
- transcript := `{"type":"human","message":{"content":"hello secret: ` + secret + `"}}
-{"type":"assistant","message":{"content":"hi there"}}
-`
- require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
-
- // SaveStep to create shadow branch
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- state.BaseCommit = commitHash.String()[:7]
- state.AgentType = agent.AgentTypeClaudeCode
-
- checkpointID := id.MustCheckpointID("dd11ee22ff33")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- require.NoError(t, err)
- require.NotNil(t, result)
-
- // v1 branch should exist (as before)
- v1Ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err, "v1 metadata branch should exist")
- require.NotEqual(t, plumbing.ZeroHash, v1Ref.Hash())
-
- // v2 /main ref should exist
- v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err, "v2 /main ref should exist")
- require.NotEqual(t, plumbing.ZeroHash, v2MainRef.Hash())
-
- // v2 /full/current ref should exist (transcript was non-empty)
- v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
- require.NoError(t, err, "v2 /full/current ref should exist")
- require.NotEqual(t, plumbing.ZeroHash, v2FullRef.Hash())
-
- // Verify /main has metadata and redacted compact transcript
- v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
- require.NoError(t, err)
- v2MainTree, err := v2MainCommit.Tree()
- require.NoError(t, err)
-
- cpPath := checkpointID.Path()
- mainCpTree, err := v2MainTree.Tree(cpPath)
- require.NoError(t, err)
-
- // Root metadata.json should exist
- _, err = mainCpTree.File(paths.MetadataFileName)
- require.NoError(t, err, "root metadata.json should exist on /main")
-
- mainSessionTree, err := mainCpTree.Tree("0")
- require.NoError(t, err)
- compactFile, err := mainSessionTree.File(paths.CompactTranscriptFileName)
- require.NoError(t, err, "transcript.jsonl should exist on /main")
- compactContent, err := compactFile.Contents()
- require.NoError(t, err)
- require.NotContains(t, compactContent, secret, "compact transcript on /main must be redacted")
-
- // Verify /full/current has transcript
- v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
- require.NoError(t, err)
- v2FullTree, err := v2FullCommit.Tree()
- require.NoError(t, err)
-
- fullCpTree, err := v2FullTree.Tree(cpPath)
- require.NoError(t, err)
- fullSessionTree, err := fullCpTree.Tree("0")
- require.NoError(t, err)
- _, err = fullSessionTree.File(paths.V2RawTranscriptFileName)
- require.NoError(t, err, "raw_transcript should exist on /full/current")
-}
-
-func TestCondenseSession_V2DualWrite_CopiesTaskMetadataToFullCurrent(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "main.go", "package main")
- testutil.GitAdd(t, dir, "main.go")
- testutil.GitCommit(t, dir, "Initial commit")
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
- commitHash := testutil.GetHeadHash(t, dir)
-
- t.Chdir(dir)
-
- traceDir := filepath.Join(dir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-v2-task-dual-write"
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"hello"}}
-{"type":"assistant","message":{"content":"hi there"}}
-`
- transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
-
- // Create shadow branch/session checkpoint data.
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- subagentTranscriptPath := filepath.Join(metadataDirAbs, "subagent.jsonl")
- require.NoError(t, os.WriteFile(subagentTranscriptPath, []byte("{\"type\":\"event\",\"message\":\"done\"}\n"), 0o644))
-
- err = s.SaveTaskStep(context.Background(), TaskStepContext{
- SessionID: sessionID,
- ToolUseID: "toolu_01TASK",
- AgentID: "agent-01",
- ModifiedFiles: []string{"main.go"},
- TranscriptPath: transcriptPath,
- SubagentTranscriptPath: subagentTranscriptPath,
- CheckpointUUID: "uuid-task-001",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- SubagentType: "general",
- TaskDescription: "Implement task",
- AgentType: agent.AgentTypeClaudeCode,
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.TranscriptPath = transcriptPath
- state.BaseCommit = commitHash[:12]
- state.AgentType = agent.AgentTypeClaudeCode
-
- checkpointID := id.MustCheckpointID("ab11cd22ef33")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- require.NoError(t, err)
- require.NotNil(t, result)
-
- v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
- require.NoError(t, err, "v2 /full/current ref should exist")
-
- v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
- require.NoError(t, err)
- v2FullTree, err := v2FullCommit.Tree()
- require.NoError(t, err)
-
- taskCheckpointPath := checkpointID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json"
- _, err = v2FullTree.File(taskCheckpointPath)
- require.NoError(t, err, "task checkpoint metadata should be copied to v2 /full/current")
-}
-
-// TestCondenseSession_V2CompactTranscriptStart verifies v2 /main writes
-// checkpoint_transcript_start from compact transcript offset, not full.jsonl offset.
-func TestCondenseSession_V2CompactTranscriptStart(t *testing.T) {
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "main.go", "package main")
- testutil.GitAdd(t, dir, "main.go")
- testutil.GitCommit(t, dir, "Initial commit")
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
- commitHash := testutil.GetHeadHash(t, dir)
-
- t.Chdir(dir)
-
- // Enable checkpoints_v2 via settings
- traceDir := filepath.Join(dir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-v2-compact-start"
-
- // Create metadata directory with transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"hello"}}
-{"type":"assistant","message":{"content":"hi there"}}
-`
- require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
-
- // SaveStep to create shadow branch
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- state.BaseCommit = commitHash[:7]
- state.AgentType = agent.AgentTypeClaudeCode
-
- // First condensation starts at compact offset 0.
- checkpointID := id.MustCheckpointID("cc11dd22ee33")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- require.NoError(t, err)
- require.NotNil(t, result)
-
- // v2 /main should have checkpoint_transcript_start = 0 for first checkpoint.
- v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- v2MainCommit, err := repo.CommitObject(v2MainRef.Hash())
- require.NoError(t, err)
- v2MainTree, err := v2MainCommit.Tree()
- require.NoError(t, err)
-
- cpPath := checkpointID.Path()
- sessionTree, err := v2MainTree.Tree(cpPath + "/0")
- require.NoError(t, err)
- metadataFile, err := sessionTree.File(paths.MetadataFileName)
- require.NoError(t, err)
- metadataContent, err := metadataFile.Contents()
- require.NoError(t, err)
-
- var v2Metadata checkpoint.CommittedMetadata
- require.NoError(t, json.Unmarshal([]byte(metadataContent), &v2Metadata))
- require.Equal(t, 0, v2Metadata.CheckpointTranscriptStart,
- "first checkpoint v2 metadata should have checkpoint_transcript_start=0")
-
- // Read v1 metadata for comparison.
- v1Ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err)
- v1Commit, err := repo.CommitObject(v1Ref.Hash())
- require.NoError(t, err)
- v1Tree, err := v1Commit.Tree()
- require.NoError(t, err)
- v1SessionTree, err := v1Tree.Tree(cpPath + "/0")
- require.NoError(t, err)
- v1MetadataFile, err := v1SessionTree.File(paths.MetadataFileName)
- require.NoError(t, err)
- v1MetadataContent, err := v1MetadataFile.Contents()
- require.NoError(t, err)
-
- var v1Metadata checkpoint.CommittedMetadata
- require.NoError(t, json.Unmarshal([]byte(v1MetadataContent), &v1Metadata))
- require.Equal(t, 0, v1Metadata.CheckpointTranscriptStart,
- "first checkpoint v1 metadata should also have checkpoint_transcript_start=0")
-
- // Verify compact transcript lines were counted in the result
- require.Positive(t, result.CompactTranscriptLines,
- "CondenseResult should report compact transcript lines")
-
- // Read compact transcript.jsonl from v2 /main for the first checkpoint.
- compactFile1, err := sessionTree.File(paths.CompactTranscriptFileName)
- require.NoError(t, err, "transcript.jsonl should exist on v2 /main")
- compactContent1, err := compactFile1.Contents()
- require.NoError(t, err)
- firstCompactLines := bytes.Count([]byte(compactContent1), []byte{'\n'})
- require.Positive(t, firstCompactLines, "first checkpoint compact transcript should have lines")
-
- // --- Second condensation: add more transcript content ---
- transcript2 := transcript + `{"type":"human","message":{"content":"next question"}}
-{"type":"assistant","message":{"content":"next answer"}}
-`
- require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript2), 0o644))
-
- // Update state after first condensation (mimic what CondenseSessionByID does)
- state.StepCount = 0
- state.CheckpointTranscriptStart = result.TotalTranscriptLines
- state.CompactTranscriptStart += result.CompactTranscriptLines
-
- // SaveStep for second checkpoint
- testutil.WriteFile(t, dir, "main.go", "package main\n// v2")
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state2, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state2.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- state2.BaseCommit = commitHash[:7]
- state2.AgentType = agent.AgentTypeClaudeCode
- state2.CheckpointTranscriptStart = state.CheckpointTranscriptStart
- state2.CompactTranscriptStart = state.CompactTranscriptStart
-
- checkpointID2 := id.MustCheckpointID("dd22ee33ff44")
- result2, err := s.CondenseSession(context.Background(), repo, checkpointID2, state2, nil)
- require.NoError(t, err)
- require.NotNil(t, result2)
-
- // v2 /main metadata for second checkpoint should have compact start = firstCompactLines.
- v2MainRef2, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- v2MainCommit2, err := repo.CommitObject(v2MainRef2.Hash())
- require.NoError(t, err)
- v2MainTree2, err := v2MainCommit2.Tree()
- require.NoError(t, err)
-
- cpPath2 := checkpointID2.Path()
- sessionTree2, err := v2MainTree2.Tree(cpPath2 + "/0")
- require.NoError(t, err)
- metadataFile2, err := sessionTree2.File(paths.MetadataFileName)
- require.NoError(t, err)
- metadataContent2, err := metadataFile2.Contents()
- require.NoError(t, err)
-
- var v2Metadata2 checkpoint.CommittedMetadata
- require.NoError(t, json.Unmarshal([]byte(metadataContent2), &v2Metadata2))
- require.Equal(t, firstCompactLines, v2Metadata2.CheckpointTranscriptStart,
- "second checkpoint v2 metadata should have checkpoint_transcript_start = first checkpoint's compact line count")
-
- // The compact transcript.jsonl for checkpoint 2 should be CUMULATIVE:
- // it should contain both checkpoint 1's and checkpoint 2's compact lines.
- compactFile2, err := sessionTree2.File(paths.CompactTranscriptFileName)
- require.NoError(t, err, "transcript.jsonl should exist for second checkpoint")
- compactContent2, err := compactFile2.Contents()
- require.NoError(t, err)
- secondCompactTotalLines := bytes.Count([]byte(compactContent2), []byte{'\n'})
- require.Greater(t, secondCompactTotalLines, firstCompactLines,
- "second checkpoint compact transcript should include all prior content plus new content")
-
- // The first checkpoint's content should be a prefix of the second checkpoint's content.
- require.True(t, strings.HasPrefix(compactContent2, compactContent1),
- "second checkpoint compact transcript should start with first checkpoint's content")
-}
-
-// TestCondenseSession_V2Disabled_NoV2Refs verifies that when checkpoints_v2 is
-// not enabled, CondenseSession only writes to v1 and does not create v2 refs.
-func TestCondenseSession_V2Disabled_NoV2Refs(t *testing.T) {
- dir := t.TempDir()
- repo, err := git.PlainInit(dir, false)
- require.NoError(t, err)
-
- worktree, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644))
- _, err = worktree.Add("main.go")
- require.NoError(t, err)
- commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- t.Chdir(dir)
-
- // No checkpoints_v2 setting — default is disabled
- traceDir := filepath.Join(dir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- settingsJSON := `{"enabled": true, "strategy": "manual-commit"}`
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsJSON), 0o644))
-
- s := &ManualCommitStrategy{}
- sessionID := "2025-01-15-test-v2-disabled"
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"hello"}}
-{"type":"assistant","message":{"content":"hi"}}
-`
- require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- state.BaseCommit = commitHash.String()[:7]
-
- checkpointID := id.MustCheckpointID("ee22ff33aa44")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 0, result.CompactTranscriptLines, "v2-disabled condensation should not report compact transcript line deltas")
-
- // v1 should exist
- _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err, "v1 metadata branch should exist")
-
- // v2 refs should NOT exist
- _, err = repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.Error(t, err, "v2 /main ref should not exist when v2 is disabled")
-
- _, err = repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
- require.Error(t, err, "v2 /full/current ref should not exist when v2 is disabled")
-}
-
-func TestCondenseSession_RedactionFailure_DropsTranscriptButWritesMetadata(t *testing.T) {
- originalRedact := redactSessionJSONLBytes
- redactSessionJSONLBytes = func([]byte) (redact.RedactedBytes, error) {
- return redact.RedactedBytes{}, errors.New("forced redaction failure")
- }
- t.Cleanup(func() {
- redactSessionJSONLBytes = originalRedact
- })
-
- dir := t.TempDir()
- testutil.InitRepo(t, dir)
- testutil.WriteFile(t, dir, "main.go", "package main")
- testutil.GitAdd(t, dir, "main.go")
- testutil.GitCommit(t, dir, "Initial commit")
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- headRef, err := repo.Head()
- require.NoError(t, err)
-
- t.Chdir(dir)
-
- s := &ManualCommitStrategy{}
- sessionID := "2026-04-10-test-redaction-failure"
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := "{\"type\":\"human\",\"message\":{\"content\":\"hello\"}}\n"
- require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"main.go"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- state.BaseCommit = headRef.Hash().String()[:7]
- state.AgentType = agent.AgentTypeClaudeCode
- state.FilesTouched = []string{"main.go"}
-
- checkpointID := id.MustCheckpointID("aa11bb22cc33")
- result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil)
- require.NoError(t, err, "redaction failure should not abort condensation")
- require.NotNil(t, result)
-
- store, err := s.getCheckpointStore()
- require.NoError(t, err)
-
- committed, err := store.ListCommitted(context.Background())
- require.NoError(t, err)
- require.NotEmpty(t, committed)
-
- found := false
- for _, c := range committed {
- if c.CheckpointID == checkpointID {
- found = true
- break
- }
- }
- require.True(t, found, "checkpoint metadata should be written even when transcript redaction fails")
-
- _, err = store.ReadLatestSessionContent(context.Background(), checkpointID)
- require.ErrorIs(t, err, checkpoint.ErrNoTranscript, "transcript should be dropped when redaction fails")
-}
-
-func TestCommittedFilesExcludingMetadata(t *testing.T) {
- t.Parallel()
-
- input := map[string]struct{}{
- "docs/blue.md": {},
- "docs/red.md": {},
- ".trace/settings.json": {},
- ".trace/.gitignore": {},
- ".claude/settings.json": {},
- }
-
- result := committedFilesExcludingMetadata(input)
-
- // .trace/ files should be excluded, everything else kept
- resultSet := make(map[string]struct{}, len(result))
- for _, f := range result {
- resultSet[f] = struct{}{}
- }
-
- require.Contains(t, resultSet, "docs/blue.md")
- require.Contains(t, resultSet, "docs/red.md")
- require.Contains(t, resultSet, ".claude/settings.json")
- require.NotContains(t, resultSet, ".trace/settings.json", ".trace/ should be excluded")
- require.NotContains(t, resultSet, ".trace/.gitignore", ".trace/ should be excluded")
- require.Len(t, result, 3)
-}
-
-func TestMarshalPromptAttributionsIncludingPending_IncludesPending(t *testing.T) {
- t.Parallel()
-
- state := &SessionState{
- PromptAttributions: []PromptAttribution{
- {CheckpointNumber: 1, UserLinesAdded: 3},
- },
- PendingPromptAttribution: &PromptAttribution{
- CheckpointNumber: 2, UserLinesAdded: 5,
- },
- }
-
- raw := marshalPromptAttributionsIncludingPending(state)
- require.NotNil(t, raw)
-
- var result []PromptAttribution
- require.NoError(t, json.Unmarshal(raw, &result))
- require.Len(t, result, 2, "should include both committed and pending attributions")
- require.Equal(t, 1, result[0].CheckpointNumber)
- require.Equal(t, 3, result[0].UserLinesAdded)
- require.Equal(t, 2, result[1].CheckpointNumber)
- require.Equal(t, 5, result[1].UserLinesAdded)
-}
-
-func TestMarshalPromptAttributionsIncludingPending_NoPending(t *testing.T) {
- t.Parallel()
-
- state := &SessionState{
- PromptAttributions: []PromptAttribution{
- {CheckpointNumber: 1, UserLinesAdded: 3},
- },
- }
-
- raw := marshalPromptAttributionsIncludingPending(state)
- require.NotNil(t, raw)
-
- var result []PromptAttribution
- require.NoError(t, json.Unmarshal(raw, &result))
- require.Len(t, result, 1)
-}
-
-func TestMarshalPromptAttributionsIncludingPending_Empty(t *testing.T) {
- t.Parallel()
-
- state := &SessionState{}
- raw := marshalPromptAttributionsIncludingPending(state)
- require.Nil(t, raw, "empty state should return nil")
-}
-
-func TestMarshalPromptAttributionsIncludingPending_OnlyPending(t *testing.T) {
- t.Parallel()
-
- state := &SessionState{
- PendingPromptAttribution: &PromptAttribution{
- CheckpointNumber: 1, UserLinesAdded: 7,
- },
- }
-
- raw := marshalPromptAttributionsIncludingPending(state)
- require.NotNil(t, raw, "pending-only should still produce output")
-
- var result []PromptAttribution
- require.NoError(t, json.Unmarshal(raw, &result))
- require.Len(t, result, 1)
- require.Equal(t, 7, result[0].UserLinesAdded)
-}
-
-func TestCommittedFilesExcludingMetadata_AllMetadata(t *testing.T) {
- t.Parallel()
-
- result := committedFilesExcludingMetadata(map[string]struct{}{
- ".trace/settings.json": {},
- ".trace/.gitignore": {},
- })
- require.Empty(t, result, "all metadata files should be excluded")
-}
diff --git a/cli/strategy/metadata_reconcile_2_test.go b/cli/strategy/metadata_reconcile_2_test.go
new file mode 100644
index 0000000..0dbd86f
--- /dev/null
+++ b/cli/strategy/metadata_reconcile_2_test.go
@@ -0,0 +1,223 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// createTestBlob stores a string as a blob and returns its hash.
+func createTestBlob(t *testing.T, repo *git.Repository, content string) plumbing.Hash {
+ t.Helper()
+ obj := repo.Storer.NewEncodedObject()
+ obj.SetType(plumbing.BlobObject)
+ w, err := obj.Writer()
+ require.NoError(t, err)
+ _, err = w.Write([]byte(content))
+ require.NoError(t, err)
+ require.NoError(t, w.Close())
+ hash, err := repo.Storer.SetEncodedObject(obj)
+ require.NoError(t, err)
+ return hash
+}
+
+func TestIsV2MainDisconnected_NoLocalRef(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ disconnected, err := IsV2MainDisconnected(context.Background(), repo, dir)
+ require.NoError(t, err)
+ assert.False(t, disconnected)
+}
+
+func TestIsV2MainDisconnected_NoRemoteRef(t *testing.T) {
+ t.Parallel()
+
+ bareDir := t.TempDir()
+ workDir := t.TempDir()
+ run := func(dir string, args ...string) {
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = dir
+ cmd.Env = testutil.GitIsolatedEnv()
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, out)
+ }
+ }
+
+ run(bareDir, "init", "--bare", "-b", "main")
+ run(workDir, "clone", bareDir, ".")
+ run(workDir, "config", "user.email", "test@test.com")
+ run(workDir, "config", "user.name", "Test User")
+ run(workDir, "config", "commit.gpgsign", "false")
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644))
+ run(workDir, "add", ".")
+ run(workDir, "commit", "-m", "init")
+ run(workDir, "push", "origin", "main")
+
+ // Create local v2 /main ref but don't push it
+ repo, err := git.PlainOpen(workDir)
+ require.NoError(t, err)
+ emptyTree := &object.Tree{Entries: []object.TreeEntry{}}
+ treeObj := repo.Storer.NewEncodedObject()
+ require.NoError(t, emptyTree.Encode(treeObj))
+ treeHash, err := repo.Storer.SetEncodedObject(treeObj)
+ require.NoError(t, err)
+ commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "init v2", "test", "test@test.com")
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), commitHash)))
+
+ disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
+ require.NoError(t, err)
+ assert.False(t, disconnected, "should not be disconnected when remote doesn't have the ref")
+}
+
+func TestIsV2MainDisconnected_Disconnected(t *testing.T) {
+ t.Parallel()
+
+ bareDir := initBareWithV2MainRef(t)
+ cloneDir, _ := cloneWithConfig(t, bareDir)
+
+ // Create a disconnected local v2 /main ref (independent orphan)
+ repo, err := git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+
+ localEntries := map[string]object.TreeEntry{
+ "cd/ef01234567/" + paths.MetadataFileName: {
+ Name: paths.MetadataFileName,
+ Mode: 0o100644,
+ Hash: createTestBlob(t, repo, `{"checkpoint_id":"cdef01234567"}`),
+ },
+ }
+ localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries)
+ require.NoError(t, err)
+ localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com")
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash)))
+
+ disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
+ require.NoError(t, err)
+ assert.True(t, disconnected, "independent orphan commits should be disconnected")
+}
+
+func TestIsV2MainDisconnected_SharedAncestry(t *testing.T) {
+ t.Parallel()
+
+ bareDir := initBareWithV2MainRef(t)
+ cloneDir, run := cloneWithConfig(t, bareDir)
+
+ // Fetch the v2 /main ref from remote
+ run("fetch", "origin", paths.V2MainRefName+":"+paths.V2MainRefName)
+
+ // Add a local commit on top (diverged but shared ancestry)
+ repo, err := git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+
+ ref, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ parentCommit, err := repo.CommitObject(ref.Hash())
+ require.NoError(t, err)
+ parentTree, err := parentCommit.Tree()
+ require.NoError(t, err)
+
+ existing := make(map[string]object.TreeEntry)
+ require.NoError(t, checkpoint.FlattenTree(repo, parentTree, "", existing))
+ existing["ef/0123456789/"+paths.MetadataFileName] = object.TreeEntry{
+ Name: paths.MetadataFileName,
+ Mode: 0o100644,
+ Hash: createTestBlob(t, repo, `{"checkpoint_id":"ef0123456789"}`),
+ }
+ newTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, existing)
+ require.NoError(t, err)
+ newCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, newTreeHash, ref.Hash(), "local checkpoint 2", "test", "test@test.com")
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), newCommitHash)))
+
+ disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
+ require.NoError(t, err)
+ assert.False(t, disconnected, "diverged with shared ancestor should not be disconnected")
+}
+
+func TestReconcileDisconnectedV2Ref_CherryPicksOntoRemote(t *testing.T) {
+ t.Parallel()
+
+ bareDir := initBareWithV2MainRef(t)
+ cloneDir, _ := cloneWithConfig(t, bareDir)
+
+ // Create disconnected local v2 /main with different checkpoint data
+ repo, err := git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+
+ localEntries := map[string]object.TreeEntry{
+ "cd/ef01234567/" + paths.MetadataFileName: {
+ Name: paths.MetadataFileName,
+ Mode: 0o100644,
+ Hash: createTestBlob(t, repo, `{"checkpoint_id":"cdef01234567"}`),
+ },
+ }
+ localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries)
+ require.NoError(t, err)
+ localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com")
+ require.NoError(t, err)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash)))
+
+ // Verify disconnected
+ disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
+ require.NoError(t, err)
+ require.True(t, disconnected, "setup: should be disconnected")
+
+ // Reconcile
+ var buf strings.Builder
+ err = ReconcileDisconnectedV2Ref(context.Background(), repo, bareDir, &buf)
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "Cherry-picking")
+ assert.Contains(t, buf.String(), "Done")
+
+ // After reconciliation, should no longer be disconnected
+ disconnected, err = IsV2MainDisconnected(context.Background(), repo, bareDir)
+ require.NoError(t, err)
+ assert.False(t, disconnected, "should be connected after reconciliation")
+
+ // Verify both remote and local checkpoint data exist in the tree
+ ref, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
+ require.NoError(t, err)
+ tipCommit, err := repo.CommitObject(ref.Hash())
+ require.NoError(t, err)
+ tree, err := tipCommit.Tree()
+ require.NoError(t, err)
+ entries := make(map[string]object.TreeEntry)
+ require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
+
+ assert.Contains(t, entries, "ab/cdef012345/"+paths.MetadataFileName, "remote checkpoint should be preserved")
+ assert.Contains(t, entries, "cd/ef01234567/"+paths.MetadataFileName, "local checkpoint should be preserved")
+}
+
+func TestReconcileDisconnectedV2Ref_NoLocalRef(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ _, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ var buf strings.Builder
+ err = ReconcileDisconnectedV2Ref(context.Background(), repo, dir, &buf)
+ require.NoError(t, err)
+ assert.Empty(t, buf.String())
+}
diff --git a/cli/strategy/metadata_reconcile_test.go b/cli/strategy/metadata_reconcile_test.go
index f9789e1..c96e0c8 100644
--- a/cli/strategy/metadata_reconcile_test.go
+++ b/cli/strategy/metadata_reconcile_test.go
@@ -820,206 +820,3 @@ func initBareWithV2MainRef(t *testing.T) string {
return bareDir
}
-
-// createTestBlob stores a string as a blob and returns its hash.
-func createTestBlob(t *testing.T, repo *git.Repository, content string) plumbing.Hash {
- t.Helper()
- obj := repo.Storer.NewEncodedObject()
- obj.SetType(plumbing.BlobObject)
- w, err := obj.Writer()
- require.NoError(t, err)
- _, err = w.Write([]byte(content))
- require.NoError(t, err)
- require.NoError(t, w.Close())
- hash, err := repo.Storer.SetEncodedObject(obj)
- require.NoError(t, err)
- return hash
-}
-
-func TestIsV2MainDisconnected_NoLocalRef(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- require.NoError(t, err)
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- disconnected, err := IsV2MainDisconnected(context.Background(), repo, dir)
- require.NoError(t, err)
- assert.False(t, disconnected)
-}
-
-func TestIsV2MainDisconnected_NoRemoteRef(t *testing.T) {
- t.Parallel()
-
- bareDir := t.TempDir()
- workDir := t.TempDir()
- run := func(dir string, args ...string) {
- cmd := exec.CommandContext(context.Background(), "git", args...)
- cmd.Dir = dir
- cmd.Env = testutil.GitIsolatedEnv()
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("git %v failed: %v\n%s", args, err, out)
- }
- }
-
- run(bareDir, "init", "--bare", "-b", "main")
- run(workDir, "clone", bareDir, ".")
- run(workDir, "config", "user.email", "test@test.com")
- run(workDir, "config", "user.name", "Test User")
- run(workDir, "config", "commit.gpgsign", "false")
- require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644))
- run(workDir, "add", ".")
- run(workDir, "commit", "-m", "init")
- run(workDir, "push", "origin", "main")
-
- // Create local v2 /main ref but don't push it
- repo, err := git.PlainOpen(workDir)
- require.NoError(t, err)
- emptyTree := &object.Tree{Entries: []object.TreeEntry{}}
- treeObj := repo.Storer.NewEncodedObject()
- require.NoError(t, emptyTree.Encode(treeObj))
- treeHash, err := repo.Storer.SetEncodedObject(treeObj)
- require.NoError(t, err)
- commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "init v2", "test", "test@test.com")
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), commitHash)))
-
- disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
- require.NoError(t, err)
- assert.False(t, disconnected, "should not be disconnected when remote doesn't have the ref")
-}
-
-func TestIsV2MainDisconnected_Disconnected(t *testing.T) {
- t.Parallel()
-
- bareDir := initBareWithV2MainRef(t)
- cloneDir, _ := cloneWithConfig(t, bareDir)
-
- // Create a disconnected local v2 /main ref (independent orphan)
- repo, err := git.PlainOpen(cloneDir)
- require.NoError(t, err)
-
- localEntries := map[string]object.TreeEntry{
- "cd/ef01234567/" + paths.MetadataFileName: {
- Name: paths.MetadataFileName,
- Mode: 0o100644,
- Hash: createTestBlob(t, repo, `{"checkpoint_id":"cdef01234567"}`),
- },
- }
- localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries)
- require.NoError(t, err)
- localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com")
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash)))
-
- disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
- require.NoError(t, err)
- assert.True(t, disconnected, "independent orphan commits should be disconnected")
-}
-
-func TestIsV2MainDisconnected_SharedAncestry(t *testing.T) {
- t.Parallel()
-
- bareDir := initBareWithV2MainRef(t)
- cloneDir, run := cloneWithConfig(t, bareDir)
-
- // Fetch the v2 /main ref from remote
- run("fetch", "origin", paths.V2MainRefName+":"+paths.V2MainRefName)
-
- // Add a local commit on top (diverged but shared ancestry)
- repo, err := git.PlainOpen(cloneDir)
- require.NoError(t, err)
-
- ref, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- parentCommit, err := repo.CommitObject(ref.Hash())
- require.NoError(t, err)
- parentTree, err := parentCommit.Tree()
- require.NoError(t, err)
-
- existing := make(map[string]object.TreeEntry)
- require.NoError(t, checkpoint.FlattenTree(repo, parentTree, "", existing))
- existing["ef/0123456789/"+paths.MetadataFileName] = object.TreeEntry{
- Name: paths.MetadataFileName,
- Mode: 0o100644,
- Hash: createTestBlob(t, repo, `{"checkpoint_id":"ef0123456789"}`),
- }
- newTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, existing)
- require.NoError(t, err)
- newCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, newTreeHash, ref.Hash(), "local checkpoint 2", "test", "test@test.com")
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), newCommitHash)))
-
- disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
- require.NoError(t, err)
- assert.False(t, disconnected, "diverged with shared ancestor should not be disconnected")
-}
-
-func TestReconcileDisconnectedV2Ref_CherryPicksOntoRemote(t *testing.T) {
- t.Parallel()
-
- bareDir := initBareWithV2MainRef(t)
- cloneDir, _ := cloneWithConfig(t, bareDir)
-
- // Create disconnected local v2 /main with different checkpoint data
- repo, err := git.PlainOpen(cloneDir)
- require.NoError(t, err)
-
- localEntries := map[string]object.TreeEntry{
- "cd/ef01234567/" + paths.MetadataFileName: {
- Name: paths.MetadataFileName,
- Mode: 0o100644,
- Hash: createTestBlob(t, repo, `{"checkpoint_id":"cdef01234567"}`),
- },
- }
- localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries)
- require.NoError(t, err)
- localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com")
- require.NoError(t, err)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash)))
-
- // Verify disconnected
- disconnected, err := IsV2MainDisconnected(context.Background(), repo, bareDir)
- require.NoError(t, err)
- require.True(t, disconnected, "setup: should be disconnected")
-
- // Reconcile
- var buf strings.Builder
- err = ReconcileDisconnectedV2Ref(context.Background(), repo, bareDir, &buf)
- require.NoError(t, err)
- assert.Contains(t, buf.String(), "Cherry-picking")
- assert.Contains(t, buf.String(), "Done")
-
- // After reconciliation, should no longer be disconnected
- disconnected, err = IsV2MainDisconnected(context.Background(), repo, bareDir)
- require.NoError(t, err)
- assert.False(t, disconnected, "should be connected after reconciliation")
-
- // Verify both remote and local checkpoint data exist in the tree
- ref, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true)
- require.NoError(t, err)
- tipCommit, err := repo.CommitObject(ref.Hash())
- require.NoError(t, err)
- tree, err := tipCommit.Tree()
- require.NoError(t, err)
- entries := make(map[string]object.TreeEntry)
- require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
-
- assert.Contains(t, entries, "ab/cdef012345/"+paths.MetadataFileName, "remote checkpoint should be preserved")
- assert.Contains(t, entries, "cd/ef01234567/"+paths.MetadataFileName, "local checkpoint should be preserved")
-}
-
-func TestReconcileDisconnectedV2Ref_NoLocalRef(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- _, err := git.PlainInit(dir, false)
- require.NoError(t, err)
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- var buf strings.Builder
- err = ReconcileDisconnectedV2Ref(context.Background(), repo, dir, &buf)
- require.NoError(t, err)
- assert.Empty(t, buf.String())
-}
diff --git a/cli/strategy/phase_postcommit_2_test.go b/cli/strategy/phase_postcommit_2_test.go
new file mode 100644
index 0000000..5731aa9
--- /dev/null
+++ b/cli/strategy/phase_postcommit_2_test.go
@@ -0,0 +1,770 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPostCommit_FilesTouched_ResetsAfterCondensation verifies that FilesTouched
+// is reset after condensation, so subsequent condensations only contain the files
+// touched since the last commit — not the accumulated history.
+func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-filestouched-reset"
+
+ // --- Round 1: Save checkpoint touching files A.txt and B.txt ---
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"round 1 prompt"}}
+{"type":"assistant","message":{"content":"round 1 response"}}
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(transcript), 0o644,
+ ))
+
+ // Create files A.txt and B.txt
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"A.txt", "B.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1: files A and B",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Set phase to IDLE so PostCommit triggers immediate condensation
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseIdle
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Verify FilesTouched has A.txt and B.txt before condensation
+ assert.ElementsMatch(t, []string{"A.txt", "B.txt"}, state.FilesTouched,
+ "FilesTouched should contain A.txt and B.txt before first condensation")
+
+ // --- Commit A.txt, B.txt and condense (round 1) ---
+ checkpointID1 := "a1a2a3a4a5a6"
+ commitFilesWithTrailer(t, repo, dir, checkpointID1, "A.txt", "B.txt")
+
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify condensation happened
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err, "trace/checkpoints/v1 should exist after first condensation")
+
+ // Verify first condensation contains A.txt and B.txt
+ store := checkpoint.NewGitStore(repo)
+ cpID1 := id.MustCheckpointID(checkpointID1)
+ summary1, err := store.ReadCommitted(context.Background(), cpID1)
+ require.NoError(t, err)
+ require.NotNil(t, summary1)
+ assert.ElementsMatch(t, []string{"A.txt", "B.txt"}, summary1.FilesTouched,
+ "First condensation should contain A.txt and B.txt")
+
+ // Verify FilesTouched was reset after condensation
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Nil(t, state.FilesTouched,
+ "FilesTouched should be nil after condensation (all files were committed)")
+
+ // --- Round 2: Save checkpoint touching files C.txt and D.txt ---
+
+ // Append to transcript for round 2
+ transcript2 := `{"type":"human","message":{"content":"round 2 prompt"}}
+{"type":"assistant","message":{"content":"round 2 response"}}
+`
+ f, err := os.OpenFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ os.O_APPEND|os.O_WRONLY, 0o644,
+ )
+ require.NoError(t, err)
+ _, err = f.WriteString(transcript2)
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ // Create files C.txt and D.txt
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "C.txt"), []byte("file C"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "D.txt"), []byte("file D"), 0o644))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"C.txt", "D.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2: files C and D",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Set phase to IDLE for immediate condensation
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseIdle
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Verify FilesTouched only has C.txt and D.txt (NOT A.txt, B.txt)
+ assert.ElementsMatch(t, []string{"C.txt", "D.txt"}, state.FilesTouched,
+ "FilesTouched should only contain C.txt and D.txt after reset")
+
+ // --- Commit C.txt, D.txt and condense (round 2) ---
+ checkpointID2 := "b1b2b3b4b5b6"
+ commitFilesWithTrailer(t, repo, dir, checkpointID2, "C.txt", "D.txt")
+
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify second condensation contains ONLY C.txt and D.txt
+ cpID2 := id.MustCheckpointID(checkpointID2)
+ summary2, err := store.ReadCommitted(context.Background(), cpID2)
+ require.NoError(t, err)
+ require.NotNil(t, summary2, "Second condensation should exist")
+ assert.ElementsMatch(t, []string{"C.txt", "D.txt"}, summary2.FilesTouched,
+ "Second condensation should only contain C.txt and D.txt, not accumulated files from first condensation")
+}
+
+// TestSubtractFiles verifies that subtractFiles correctly removes files present
+// in the exclude set and preserves files not in it.
+func TestSubtractFiles(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ files []string
+ exclude map[string]struct{}
+ expected []string
+ }{
+ {
+ name: "no overlap",
+ files: []string{"a.txt", "b.txt"},
+ exclude: map[string]struct{}{"c.txt": {}},
+ expected: []string{"a.txt", "b.txt"},
+ },
+ {
+ name: "full overlap",
+ files: []string{"a.txt", "b.txt"},
+ exclude: map[string]struct{}{"a.txt": {}, "b.txt": {}},
+ expected: nil,
+ },
+ {
+ name: "partial overlap",
+ files: []string{"a.txt", "b.txt", "c.txt"},
+ exclude: map[string]struct{}{"b.txt": {}},
+ expected: []string{"a.txt", "c.txt"},
+ },
+ {
+ name: "empty files",
+ files: []string{},
+ exclude: map[string]struct{}{"a.txt": {}},
+ expected: nil,
+ },
+ {
+ name: "empty exclude",
+ files: []string{"a.txt", "b.txt"},
+ exclude: map[string]struct{}{},
+ expected: []string{"a.txt", "b.txt"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result := subtractFiles(tt.files, tt.exclude)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestFilesChangedInCommit verifies that filesChangedInCommit correctly extracts
+// the set of files changed in a commit by diffing against its parent.
+func TestFilesChangedInCommit(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ // Create files and commit them
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "file1.txt"), []byte("content1"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "file2.txt"), []byte("content2"), 0o644))
+ _, err = wt.Add("file1.txt")
+ require.NoError(t, err)
+ _, err = wt.Add("file2.txt")
+ require.NoError(t, err)
+
+ commitHash, err := wt.Commit("add files", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ commit, err := repo.CommitObject(commitHash)
+ require.NoError(t, err)
+
+ headTree, err := commit.Tree()
+ require.NoError(t, err)
+ var parentTree *object.Tree
+ if commit.NumParents() > 0 {
+ parent, pErr := commit.Parent(0)
+ require.NoError(t, pErr)
+ parentTree, err = parent.Tree()
+ require.NoError(t, err)
+ }
+
+ changed := filesChangedInCommit(context.Background(), dir, commit, headTree, parentTree)
+ assert.Contains(t, changed, "file1.txt")
+ assert.Contains(t, changed, "file2.txt")
+ // test.txt was in the initial commit, not this one
+ assert.NotContains(t, changed, "test.txt")
+}
+
+// TestFilesChangedInCommit_InitialCommit verifies that filesChangedInCommit
+// handles the initial commit (no parent) by listing all files.
+func TestFilesChangedInCommit_InitialCommit(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+
+ repo, err := git.PlainInit(dir, false)
+ require.NoError(t, err)
+
+ cfg, err := repo.Config()
+ require.NoError(t, err)
+ cfg.User.Name = "Test"
+ cfg.User.Email = "test@test.com"
+ require.NoError(t, repo.SetConfig(cfg))
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("initial"), 0o644))
+ _, err = wt.Add("init.txt")
+ require.NoError(t, err)
+
+ commitHash, err := wt.Commit("initial", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ commit, err := repo.CommitObject(commitHash)
+ require.NoError(t, err)
+
+ headTree, err := commit.Tree()
+ require.NoError(t, err)
+
+ changed := filesChangedInCommit(context.Background(), dir, commit, headTree, nil)
+ assert.Contains(t, changed, "init.txt")
+ assert.Len(t, changed, 1)
+}
+
+// TestFilesChangedInCommit_FallbackOnBadRepoDir verifies that when git diff-tree fails
+// (e.g. invalid repoDir), filesChangedInCommit falls back to go-git tree walk and still
+// returns correct results instead of an empty map.
+func TestFilesChangedInCommit_FallbackOnBadRepoDir(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644))
+ _, err = wt.Add("new.txt")
+ require.NoError(t, err)
+
+ commitHash, err := wt.Commit("add new file", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ commit, err := repo.CommitObject(commitHash)
+ require.NoError(t, err)
+
+ headTree, err := commit.Tree()
+ require.NoError(t, err)
+ var parentTree *object.Tree
+ if commit.NumParents() > 0 {
+ parent, pErr := commit.Parent(0)
+ require.NoError(t, pErr)
+ parentTree, err = parent.Tree()
+ require.NoError(t, err)
+ }
+
+ // Pass a bogus repoDir to force git diff-tree to fail, triggering the fallback
+ changed := filesChangedInCommit(context.Background(), "/nonexistent/repo", commit, headTree, parentTree)
+
+ // Fallback should still detect the changed file via go-git tree walk
+ assert.Contains(t, changed, "new.txt")
+ assert.NotEmpty(t, changed, "fallback should return files, not empty map")
+}
+
+// TestPostCommit_ActiveSession_CarryForward_PartialCommit verifies that when an
+// ACTIVE session has touched files A, B, C but only A and B are committed, the
+// remaining file C is carried forward to a new shadow branch.
+func TestPostCommit_ActiveSession_CarryForward_PartialCommit(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-carry-forward-partial"
+
+ // Create metadata directory with transcript
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"create files A B C"}}
+{"type":"assistant","message":{"content":"creating files"}}
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(transcript), 0o644,
+ ))
+
+ // Create all three files
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "C.txt"), []byte("file C"), 0o644))
+
+ // Save checkpoint with all three files
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"A.txt", "B.txt", "C.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint: files A, B, C",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Set phase to ACTIVE (agent mid-turn)
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Verify FilesTouched contains all three files
+ assert.ElementsMatch(t, []string{"A.txt", "B.txt", "C.txt"}, state.FilesTouched)
+
+ // Commit ONLY A.txt and B.txt (not C.txt) with checkpoint trailer
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("A.txt")
+ require.NoError(t, err)
+ _, err = wt.Add("B.txt")
+ require.NoError(t, err)
+
+ cpID := "cf1cf2cf3cf4"
+ commitMsg := "commit A and B\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify session stayed ACTIVE
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Equal(t, session.PhaseActive, state.Phase)
+
+ // Verify carry-forward: FilesTouched should now only contain C.txt
+ assert.Equal(t, []string{"C.txt"}, state.FilesTouched,
+ "carry-forward should preserve only the uncommitted file C.txt")
+
+ // Verify StepCount was set to 1 (carry-forward creates a new checkpoint)
+ assert.Equal(t, 1, state.StepCount,
+ "carry-forward should set StepCount to 1")
+
+ // Verify CheckpointTranscriptStart was reset to 0 (prompt-level carry-forward)
+ assert.Equal(t, 0, state.CheckpointTranscriptStart,
+ "carry-forward should reset CheckpointTranscriptStart to 0 for full transcript reprocessing")
+
+ // Verify LastCheckpointID was cleared (next commit generates fresh ID)
+ assert.Empty(t, state.LastCheckpointID,
+ "carry-forward should clear LastCheckpointID")
+
+ // Verify a new shadow branch exists at the new HEAD
+ newShadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(newShadowBranch), true)
+ assert.NoError(t, err,
+ "carry-forward should create a new shadow branch at the new HEAD")
+}
+
+// TestPostCommit_ActiveSession_CarryForward_AllCommitted verifies that when an
+// ACTIVE session's files are ALL included in the commit, no carry-forward occurs.
+func TestPostCommit_ActiveSession_CarryForward_AllCommitted(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-carry-forward-all"
+
+ // Initialize session and save a checkpoint with files A and B
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"create files A B"}}
+{"type":"assistant","message":{"content":"creating files"}}
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(transcript), 0o644,
+ ))
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"A.txt", "B.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint: files A, B",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Set phase to ACTIVE
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Commit ALL files (A.txt and B.txt) with checkpoint trailer
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("A.txt")
+ require.NoError(t, err)
+ _, err = wt.Add("B.txt")
+ require.NoError(t, err)
+
+ cpID := "cf5cf6cf7cf8"
+ commitMsg := "commit A and B\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify session stayed ACTIVE
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Equal(t, session.PhaseActive, state.Phase)
+
+ // Verify NO carry-forward: FilesTouched should be nil (all condensed, nothing remaining)
+ assert.Nil(t, state.FilesTouched,
+ "when all files are committed, no carry-forward should occur (FilesTouched cleared by condensation)")
+
+ // Verify StepCount was reset to 0 by condensation (not 1 from carry-forward)
+ assert.Equal(t, 0, state.StepCount,
+ "without carry-forward, StepCount should be reset to 0 by condensation")
+}
+
+// TestPostCommit_ActiveSession_RecordsTurnCheckpointIDs verifies that PostCommit
+// records the checkpoint ID in TurnCheckpointIDs for ACTIVE sessions.
+// This enables HandleTurnEnd to finalize all checkpoints with the full transcript.
+func TestPostCommit_ActiveSession_RecordsTurnCheckpointIDs(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-turn-checkpoint-ids"
+
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to ACTIVE (simulating agent mid-turn)
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ state.TurnCheckpointIDs = nil // Start clean
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Create first commit with checkpoint trailer
+ commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
+
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify TurnCheckpointIDs was populated
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"a1b2c3d4e5f6"}, state.TurnCheckpointIDs,
+ "TurnCheckpointIDs should contain the checkpoint ID after condensation")
+}
+
+// TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs verifies that PostCommit
+// does NOT record TurnCheckpointIDs for IDLE sessions.
+func TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-idle-no-turn-ids"
+
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to IDLE with files touched so overlap check passes
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseIdle
+ state.FilesTouched = []string{"test.txt"}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ commitWithCheckpointTrailer(t, repo, dir, "c3d4e5f6a1b2")
+
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify TurnCheckpointIDs was NOT set (IDLE sessions don't need finalization)
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Empty(t, state.TurnCheckpointIDs,
+ "TurnCheckpointIDs should not be populated for IDLE sessions")
+}
+
+// TestHandleTurnEnd_PartialFailure verifies that HandleTurnEnd continues
+// processing remaining checkpoints when one UpdateCommitted call fails.
+// This locks the best-effort behavior: valid checkpoints get finalized even
+// when one checkpoint ID is invalid or missing from trace/checkpoints/v1.
+func TestHandleTurnEnd_PartialFailure(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-partial-failure"
+
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to ACTIVE and create a transcript file with updated content
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ state.TurnCheckpointIDs = nil
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // First commit → creates real checkpoint on trace/checkpoints/v1
+ commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ // Write new content and create a second checkpoint on the shadow branch.
+ // Use SaveStep directly (instead of setupSessionWithCheckpoint) so that
+ // second.txt is included in FilesTouched — the overlap check needs it.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644))
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.txt"},
+ NewFiles: []string{"second.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err, "SaveStep should succeed for second checkpoint")
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ // Preserve TurnCheckpointIDs from the first commit
+ state.TurnCheckpointIDs = []string{"a1b2c3d4e5f6"}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ commitFilesWithTrailer(t, repo, dir, "b2c3d4e5f6a1", "second.txt")
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ // Verify we now have 2 real checkpoint IDs
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ require.Len(t, state.TurnCheckpointIDs, 2,
+ "Should have 2 real checkpoint IDs after 2 mid-turn commits")
+
+ // Inject a fake 3rd checkpoint ID that doesn't exist on trace/checkpoints/v1
+ state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, "ffffffffffff")
+
+ // Write a full transcript file for HandleTurnEnd to read
+ fullTranscript := `{"type":"human","message":{"content":"build something"}}
+{"type":"assistant","message":{"content":"done building"}}
+{"type":"human","message":{"content":"now test it"}}
+{"type":"assistant","message":{"content":"tests pass"}}
+`
+ transcriptPath := filepath.Join(dir, ".trace", "metadata", sessionID, "full_transcript.jsonl")
+ require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755))
+ require.NoError(t, os.WriteFile(transcriptPath, []byte(fullTranscript), 0o644))
+ state.TranscriptPath = transcriptPath
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Call HandleTurnEnd — should NOT return error (best-effort)
+ err = s.HandleTurnEnd(context.Background(), state)
+ require.NoError(t, err,
+ "HandleTurnEnd should return nil even with partial failures (best-effort)")
+
+ // TurnCheckpointIDs should be cleared regardless of partial failure
+ assert.Empty(t, state.TurnCheckpointIDs,
+ "TurnCheckpointIDs should be cleared after HandleTurnEnd, even with errors")
+
+ // Verify the 2 valid checkpoints were finalized with the full transcript
+ store := checkpoint.NewGitStore(repo)
+ for _, cpIDStr := range []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"} {
+ cpID := id.MustCheckpointID(cpIDStr)
+ content, readErr := store.ReadSessionContent(context.Background(), cpID, 0)
+ require.NoError(t, readErr,
+ "Should be able to read finalized checkpoint %s", cpIDStr)
+ assert.Contains(t, string(content.Transcript), "now test it",
+ "Checkpoint %s should contain the full transcript (including later messages)", cpIDStr)
+ }
+}
+
+func TestHandleTurnEnd_V2FullCurrent_PreservesTaskMetadata(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ // Enable checkpoints_v2 dual-write so PostCommit/HandleTurnEnd update v2 refs.
+ traceDir := filepath.Join(dir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-turn-end-v2-task-preserve"
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+ transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
+ require.NoError(t, os.WriteFile(transcriptPath, []byte(testTranscriptPromptResponse), 0o644))
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("agent modified content"), 0o644))
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.txt"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ subagentTranscriptPath := filepath.Join(metadataDirAbs, "subagent.jsonl")
+ require.NoError(t, os.WriteFile(subagentTranscriptPath, []byte("{\"type\":\"event\",\"message\":\"done\"}\n"), 0o644))
+ err = s.SaveTaskStep(context.Background(), TaskStepContext{
+ SessionID: sessionID,
+ ToolUseID: "toolu_01TASK",
+ AgentID: "agent-01",
+ ModifiedFiles: []string{"test.txt"},
+ TranscriptPath: transcriptPath,
+ SubagentTranscriptPath: subagentTranscriptPath,
+ CheckpointUUID: "uuid-task-001",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ SubagentType: "general",
+ TaskDescription: "Implement task",
+ AgentType: agent.AgentTypeClaudeCode,
+ })
+ require.NoError(t, err)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ state.TurnCheckpointIDs = nil
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ cpID := "a1b2c3d4e5f6"
+ commitWithCheckpointTrailer(t, repo, dir, cpID)
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ require.Equal(t, []string{cpID}, state.TurnCheckpointIDs)
+
+ fullTranscript := `{"type":"human","message":{"content":"final user prompt"}}
+{"type":"assistant","message":{"content":"final assistant response"}}
+`
+ require.NoError(t, os.WriteFile(transcriptPath, []byte(fullTranscript), 0o644))
+ state.TranscriptPath = transcriptPath
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ require.NoError(t, s.HandleTurnEnd(context.Background(), state))
+
+ v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
+ require.NoError(t, err)
+ v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
+ require.NoError(t, err)
+ v2FullTree, err := v2FullCommit.Tree()
+ require.NoError(t, err)
+
+ checkpointID := id.MustCheckpointID(cpID)
+ _, err = v2FullTree.File(checkpointID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json")
+ require.NoError(t, err, "task metadata should be preserved after HandleTurnEnd finalization")
+}
diff --git a/cli/strategy/phase_postcommit_3_test.go b/cli/strategy/phase_postcommit_3_test.go
new file mode 100644
index 0000000..b5e9226
--- /dev/null
+++ b/cli/strategy/phase_postcommit_3_test.go
@@ -0,0 +1,816 @@
+package strategy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandleTurnEnd_V2UsesExternalTranscriptCompactor(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ agentName := types.AgentName("test-external-turn-end-compactor")
+ agentType := types.AgentType("Test External Turn End Compactor")
+ fakeAgent := &fakeTranscriptCompactorAgent{
+ name: agentName,
+ agentType: agentType,
+ fullCompact: []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"initial\"}\n"),
+ caps: agent.DeclaredCaps{CompactTranscript: true},
+ }
+ agent.Register(agentName, func() agent.Agent { return fakeAgent })
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-turn-end-external-compactor"
+
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.AgentType = agentType
+ state.Phase = session.PhaseActive
+ state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
+ state.TurnCheckpointIDs = nil
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ cpIDStr := testTrailerCheckpointID.String()
+ commitWithCheckpointTrailer(t, repo, dir, cpIDStr)
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ cpID := testTrailerCheckpointID
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ initialCompact, err := v2Store.ReadSessionCompactTranscript(context.Background(), cpID, 0)
+ require.NoError(t, err)
+ require.Equal(t, fakeAgent.fullCompact, initialCompact)
+
+ updatedTranscript := `{"type":"human","message":{"content":"build something"}}
+{"type":"assistant","message":{"content":"done building"}}
+{"type":"human","message":{"content":"now finalize it"}}
+{"type":"assistant","message":{"content":"all done"}}
+`
+ require.NoError(t, os.WriteFile(state.TranscriptPath, []byte(updatedTranscript), 0o644))
+ fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"final\"}\n")
+
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ require.Equal(t, []string{cpIDStr}, state.TurnCheckpointIDs)
+
+ require.NoError(t, s.HandleTurnEnd(context.Background(), state))
+
+ finalCompact, err := v2Store.ReadSessionCompactTranscript(context.Background(), cpID, 0)
+ require.NoError(t, err)
+ require.Equal(t, fakeAgent.fullCompact, finalCompact)
+}
+
+func TestHandleTurnEnd_V2ExternalTranscriptCompactor_UpdatesAllTurnCheckpoints(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
+
+ agentName := types.AgentName("test-external-turn-end-multi-compactor")
+ agentType := types.AgentType("Test External Turn End Multi Compactor")
+ fakeAgent := &fakeTranscriptCompactorAgent{
+ name: agentName,
+ agentType: agentType,
+ fullCompact: []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-1\"}\n"),
+ caps: agent.DeclaredCaps{CompactTranscript: true},
+ }
+ agent.Register(agentName, func() agent.Agent { return fakeAgent })
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-turn-end-external-nonzero-offset"
+
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.AgentType = agentType
+ state.Phase = session.PhaseActive
+ state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
+ state.TurnCheckpointIDs = nil
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ cpID1 := "a1b2c3d4e5f6"
+ commitWithCheckpointTrailer(t, repo, dir, cpID1)
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-2\"}\n")
+
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644))
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.txt"},
+ NewFiles: []string{"second.txt"},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 2",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.AgentType = agentType
+ state.Phase = session.PhaseActive
+ state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ cpID2 := "b2c3d4e5f6a1"
+ commitFilesWithTrailer(t, repo, dir, cpID2, "second.txt")
+ require.NoError(t, s.PostCommit(context.Background()))
+
+ v2Store := checkpoint.NewV2GitStore(repo, "origin")
+ initialCompact1, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID1), 0)
+ require.NoError(t, err)
+ require.JSONEq(t, "{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-1\"}\n", string(initialCompact1))
+ initialContent1, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID1), sessionID)
+ require.NoError(t, err)
+ initialStart1 := initialContent1.Metadata.CheckpointTranscriptStart
+
+ initialCompact2, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID2), 0)
+ require.NoError(t, err)
+ require.JSONEq(t, "{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-2\"}\n", string(initialCompact2))
+ initialContent2, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID2), sessionID)
+ require.NoError(t, err)
+ initialStart2 := initialContent2.Metadata.CheckpointTranscriptStart
+ require.Greater(t, initialStart2, initialStart1, "later checkpoints should start later in transcript.jsonl")
+
+ updatedTranscript := `{"type":"human","message":{"content":"build something"}}
+{"type":"assistant","message":{"content":"done building"}}
+{"type":"human","message":{"content":"now finalize it"}}
+{"type":"assistant","message":{"content":"all done"}}
+`
+ require.NoError(t, os.WriteFile(state.TranscriptPath, []byte(updatedTranscript), 0o644))
+ fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"final\"}\n")
+
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ require.Equal(t, []string{cpID1, cpID2}, state.TurnCheckpointIDs)
+
+ require.NoError(t, s.HandleTurnEnd(context.Background(), state))
+
+ finalCompact1, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID1), 0)
+ require.NoError(t, err)
+ require.Equal(t, fakeAgent.fullCompact, finalCompact1)
+ finalContent1, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID1), sessionID)
+ require.NoError(t, err)
+ require.Equal(t, initialStart1, finalContent1.Metadata.CheckpointTranscriptStart, "finalization must not rewrite checkpoint start offsets")
+
+ finalCompact2, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID2), 0)
+ require.NoError(t, err)
+ require.Equal(t, fakeAgent.fullCompact, finalCompact2)
+ finalContent2, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID2), sessionID)
+ require.NoError(t, err)
+ require.Equal(t, initialStart2, finalContent2.Metadata.CheckpointTranscriptStart, "finalization must preserve per-checkpoint line references")
+}
+
+// setupSessionWithCheckpoint initializes a session and creates one checkpoint
+// on the shadow branch so there is content available for condensation.
+// Also modifies test.txt to "agent modified content" and includes it in the checkpoint,
+// so content-aware carry-forward comparisons work correctly when commitFilesWithTrailer
+// commits the same content.
+func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Repository, dir, sessionID string) {
+ t.Helper()
+
+ // Modify test.txt with agent content (same content that commitFilesWithTrailer will commit)
+ testFile := filepath.Join(dir, "test.txt")
+ require.NoError(t, os.WriteFile(testFile, []byte("agent modified content"), 0o644))
+
+ // Create metadata directory with a transcript file
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(testTranscriptPromptResponse), 0o644,
+ ))
+
+ // SaveStep creates the shadow branch and checkpoint
+ // Include test.txt as a modified file so it's saved to the shadow branch
+ err := s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{"test.txt"},
+ NewFiles: []string{},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err, "SaveStep should succeed to create shadow branch content")
+}
+
+// setupSessionWithCheckpointAndFile initializes a session with a checkpoint for
+// a caller-provided new file. This lets tests create multiple independent
+// sessions that all overlap with the same commit.
+func setupSessionWithCheckpointAndFile(t *testing.T, s *ManualCommitStrategy, dir, sessionID, fileName string) {
+ t.Helper()
+
+ filePath := filepath.Join(dir, fileName)
+ fileContent := "agent content for " + fileName
+ require.NoError(t, os.WriteFile(filePath, []byte(fileContent), 0o644))
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(testTranscript), 0o644,
+ ))
+
+ err := s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{fileName},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint 1",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err, "SaveStep should succeed to create shadow branch content")
+}
+
+// shadowTranscriptSize returns the byte size of the transcript blob on the shadow branch.
+// Used in tests to set CheckpointTranscriptSize without hardcoding sizes.
+func shadowTranscriptSize(t *testing.T, repo *git.Repository, state *SessionState) int64 {
+ t.Helper()
+ shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
+ ref, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
+ require.NoError(t, err)
+ commit, err := repo.CommitObject(ref.Hash())
+ require.NoError(t, err)
+ tree, err := commit.Tree()
+ require.NoError(t, err)
+ metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
+ size, err := tree.Size(metadataDir + "/" + paths.TranscriptFileName)
+ require.NoError(t, err)
+ return size
+}
+
+// commitWithCheckpointTrailer creates a commit on the current branch with the
+// Trace-Checkpoint trailer in the commit message. This simulates what happens
+// after PrepareCommitMsg adds the trailer and the user completes the commit.
+func commitWithCheckpointTrailer(t *testing.T, repo *git.Repository, dir, checkpointIDStr string) {
+ t.Helper()
+ commitFilesWithTrailer(t, repo, dir, checkpointIDStr, "test.txt")
+}
+
+// commitFilesWithTrailer stages the given files and commits with a checkpoint trailer.
+// Files must already exist on disk. The test.txt file is modified to ensure there's always something to commit.
+// Important: For tests using content-aware carry-forward, call setupSessionWithCheckpointAndFile first
+// so the shadow branch has the same content that will be committed.
+func commitFilesWithTrailer(t *testing.T, repo *git.Repository, dir, checkpointIDStr string, files ...string) {
+ t.Helper()
+
+ cpID := id.MustCheckpointID(checkpointIDStr)
+
+ // Modify test.txt with agent-like content that matches what setupSessionWithCheckpointAndFile saves
+ testFile := filepath.Join(dir, "test.txt")
+ content := "agent modified content"
+ require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644))
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+ for _, f := range files {
+ _, err = wt.Add(f)
+ require.NoError(t, err)
+ }
+
+ commitMsg := "test commit\n\n" + trailers.CheckpointTrailerKey + ": " + cpID.String() + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@test.com",
+ When: time.Now(),
+ },
+ })
+ require.NoError(t, err, "commit with checkpoint trailer should succeed")
+}
+
+// TestPostCommit_OldIdleSession_BaseCommitNotUpdated verifies that when an IDLE
+// session from a previous commit exists, and a NEW session makes a commit, the
+// old IDLE session's BaseCommit is NOT updated to the new HEAD.
+//
+// This is a regression test for the bug where old sessions (IDLE/ENDED) would
+// have their BaseCommit updated, causing them to be incorrectly condensed on
+// future commits because their BaseCommit matched the new shadow branch.
+func TestPostCommit_OldIdleSession_BaseCommitNotUpdated(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // --- Create an old IDLE session from a previous commit ---
+ oldSessionID := "old-idle-session"
+ setupSessionWithCheckpoint(t, s, repo, dir, oldSessionID)
+
+ oldState, err := s.loadSessionState(context.Background(), oldSessionID)
+ require.NoError(t, err)
+ oldState.Phase = session.PhaseIdle
+ oldState.FilesTouched = []string{"old-file.txt"} // Has files touched (important for bug)
+ require.NoError(t, s.saveSessionState(context.Background(), oldState))
+
+ // Record the old session's BaseCommit BEFORE the new commit
+ oldSessionOriginalBaseCommit := oldState.BaseCommit
+
+ // Create a commit to move HEAD forward (simulating old session was condensed)
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated"), 0o644))
+ _, err = wt.Add("unrelated.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("unrelated commit without trailer", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // --- Create a NEW ACTIVE session at the new HEAD ---
+ newSessionID := testNewActiveSessionID
+ setupSessionWithCheckpoint(t, s, repo, dir, newSessionID)
+
+ newState, err := s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+ newState.Phase = session.PhaseActive
+ require.NoError(t, s.saveSessionState(context.Background(), newState))
+
+ // --- Commit from the new session ---
+ commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
+
+ // Get new HEAD for comparison
+ head, err := repo.Head()
+ require.NoError(t, err)
+ newHead := head.Hash().String()
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // --- Verify: old IDLE session's BaseCommit should NOT be updated ---
+ oldState, err = s.loadSessionState(context.Background(), oldSessionID)
+ require.NoError(t, err)
+ assert.Equal(t, oldSessionOriginalBaseCommit, oldState.BaseCommit,
+ "OLD IDLE session's BaseCommit should NOT be updated when a different session commits")
+ assert.NotEqual(t, newHead, oldState.BaseCommit,
+ "OLD IDLE session's BaseCommit should NOT match new HEAD")
+
+ // New ACTIVE session's BaseCommit SHOULD be updated (it was condensed)
+ newState, err = s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+ assert.Equal(t, newHead, newState.BaseCommit,
+ "NEW ACTIVE session's BaseCommit should be updated after condensation")
+}
+
+// TestPostCommit_OldEndedSession_BaseCommitNotUpdated verifies that when an ENDED
+// session from a previous commit exists (with no new content to condense), and a
+// NEW session makes a commit, the old ENDED session's BaseCommit is NOT updated.
+//
+// This simulates the scenario where:
+// 1. Old session ran and was already condensed (no new transcript content)
+// 2. Old session is now ENDED
+// 3. New session commits
+// 4. Old ENDED session should NOT have BaseCommit updated
+func TestPostCommit_OldEndedSession_BaseCommitNotUpdated(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // --- Create an old ENDED session that has NO new content to condense ---
+ oldSessionID := "old-ended-session"
+ setupSessionWithCheckpoint(t, s, repo, dir, oldSessionID)
+
+ oldState, err := s.loadSessionState(context.Background(), oldSessionID)
+ require.NoError(t, err)
+ now := time.Now()
+ oldState.Phase = session.PhaseEnded
+ oldState.EndedAt = &now
+ oldState.FilesTouched = []string{"old-file.txt"} // Has files touched
+ // Mark transcript as fully condensed (no new content since last checkpoint)
+ // The transcript has 2 lines, so CheckpointTranscriptStart=2 means no new content
+ oldState.CheckpointTranscriptStart = 2
+ require.NoError(t, s.saveSessionState(context.Background(), oldState))
+
+ // Record the old session's BaseCommit BEFORE the new commit
+ oldSessionOriginalBaseCommit := oldState.BaseCommit
+
+ // Create a commit to move HEAD forward
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated"), 0o644))
+ _, err = wt.Add("unrelated.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("unrelated commit without trailer", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // --- Create a NEW ACTIVE session at the new HEAD ---
+ newSessionID := testNewActiveSessionID
+ setupSessionWithCheckpoint(t, s, repo, dir, newSessionID)
+
+ newState, err := s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+ newState.Phase = session.PhaseActive
+ require.NoError(t, s.saveSessionState(context.Background(), newState))
+
+ // --- Commit from the new session ---
+ commitWithCheckpointTrailer(t, repo, dir, "b1c2d3e4f5a6")
+
+ // Get new HEAD for comparison
+ head, err := repo.Head()
+ require.NoError(t, err)
+ newHead := head.Hash().String()
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // --- Verify: old ENDED session's BaseCommit should NOT be updated ---
+ oldState, err = s.loadSessionState(context.Background(), oldSessionID)
+ require.NoError(t, err)
+ assert.Equal(t, oldSessionOriginalBaseCommit, oldState.BaseCommit,
+ "OLD ENDED session's BaseCommit should NOT be updated when a different session commits")
+ assert.NotEqual(t, newHead, oldState.BaseCommit,
+ "OLD ENDED session's BaseCommit should NOT match new HEAD")
+
+ // New ACTIVE session's BaseCommit SHOULD be updated
+ newState, err = s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+ assert.Equal(t, newHead, newState.BaseCommit,
+ "NEW ACTIVE session's BaseCommit should be updated after condensation")
+}
+
+// TestPostCommit_StaleActiveSession_NotCondensed verifies that a stale ACTIVE
+// session (agent killed without Stop hook) is NOT condensed into an unrelated
+// commit from a different session.
+//
+// Root cause: when an agent is killed without the Stop hook firing, its session
+// remains in ACTIVE phase permanently. The overlap check prevents stale sessions
+// with unrelated files from being condensed. The isRecentInteraction guard
+// ensures that genuinely-active sessions (recent LastInteractionTime) skip the
+// overlap check, while stale sessions (old/nil LastInteractionTime) must pass it.
+func TestPostCommit_StaleActiveSession_NotCondensed(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // --- Create a stale ACTIVE session from an old commit ---
+ // This simulates an agent that was killed without the Stop hook firing.
+ staleSessionID := "stale-active-session"
+ setupSessionWithCheckpoint(t, s, repo, dir, staleSessionID)
+
+ staleState, err := s.loadSessionState(context.Background(), staleSessionID)
+ require.NoError(t, err)
+ staleState.Phase = session.PhaseActive
+ // The stale session touched "test.txt" (set by setupSessionWithCheckpoint)
+ // but the new commit will modify a different file.
+ staleState.FilesTouched = []string{"test.txt"}
+ // Stale session: LastInteractionTime is old (agent was killed days ago)
+ staleTime := time.Now().Add(-48 * time.Hour)
+ staleState.LastInteractionTime = &staleTime
+ require.NoError(t, s.saveSessionState(context.Background(), staleState))
+
+ staleOriginalBaseCommit := staleState.BaseCommit
+ staleOriginalStepCount := staleState.StepCount
+
+ // Move HEAD forward with an unrelated commit (no trailer)
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated work"), 0o644))
+ _, err = wt.Add("unrelated.txt")
+ require.NoError(t, err)
+ _, err = wt.Commit("unrelated commit", &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // --- Create a NEW ACTIVE session at the new HEAD ---
+ newSessionID := testNewActiveSessionID
+
+ // Create a new file for the new session (different from stale session's test.txt)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "new-feature.txt"), []byte("new feature content"), 0o644))
+
+ metadataDir := ".trace/metadata/" + newSessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"add new feature"}}
+{"type":"assistant","message":{"content":"adding new feature"}}
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(transcript), 0o644,
+ ))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: newSessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{"new-feature.txt"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint: new feature",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ newState, err := s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+ newState.Phase = session.PhaseActive
+ // New session has recent interaction (agent is genuinely running)
+ now := time.Now()
+ newState.LastInteractionTime = &now
+ require.NoError(t, s.saveSessionState(context.Background(), newState))
+
+ // --- Commit ONLY new-feature.txt (not test.txt) with checkpoint trailer ---
+ wt, err = repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("new-feature.txt")
+ require.NoError(t, err)
+
+ cpID := "de1de2de3de4"
+ commitMsg := "add new feature\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ head, err := repo.Head()
+ require.NoError(t, err)
+ newHead := head.Hash().String()
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // --- Verify: stale ACTIVE session was NOT condensed ---
+ staleState, err = s.loadSessionState(context.Background(), staleSessionID)
+ require.NoError(t, err)
+
+ // StepCount should be unchanged (not reset by condensation)
+ assert.Equal(t, staleOriginalStepCount, staleState.StepCount,
+ "Stale ACTIVE session StepCount should NOT be reset (no condensation)")
+
+ // BaseCommit IS updated for ACTIVE sessions (updateBaseCommitIfChanged)
+ assert.Equal(t, newHead, staleState.BaseCommit,
+ "Stale ACTIVE session BaseCommit should be updated (ACTIVE sessions always get BaseCommit updated)")
+ assert.NotEqual(t, staleOriginalBaseCommit, staleState.BaseCommit,
+ "Stale ACTIVE session BaseCommit should have changed")
+
+ // Phase stays ACTIVE
+ assert.Equal(t, session.PhaseActive, staleState.Phase,
+ "Stale ACTIVE session should remain ACTIVE")
+
+ // --- Verify: new ACTIVE session WAS condensed ---
+ newState, err = s.loadSessionState(context.Background(), newSessionID)
+ require.NoError(t, err)
+
+ // StepCount reset to 0 by condensation
+ assert.Equal(t, 0, newState.StepCount,
+ "New ACTIVE session StepCount should be reset by condensation")
+
+ // BaseCommit updated to new HEAD
+ assert.Equal(t, newHead, newState.BaseCommit,
+ "New ACTIVE session BaseCommit should be updated after condensation")
+
+ // Verify trace/checkpoints/v1 exists (new session was condensed)
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err,
+ "trace/checkpoints/v1 should exist (new session was condensed)")
+}
+
+// TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed verifies that an IDLE
+// session with hasNew=true but empty FilesTouched is NOT condensed into a commit.
+//
+// This can happen for conversation-only sessions where the transcript grew but no
+// files were modified. Previously, filesOverlapWithContent was called with an empty
+// list and returned false. The shouldCondenseWithOverlapCheck method must also
+// return false when filesTouchedBefore is empty.
+func TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // --- Create an IDLE session with a checkpoint but no files touched ---
+ idleSessionID := "idle-no-files-session"
+ setupSessionWithCheckpoint(t, s, repo, dir, idleSessionID)
+
+ idleState, err := s.loadSessionState(context.Background(), idleSessionID)
+ require.NoError(t, err)
+ idleState.Phase = session.PhaseIdle
+ // Clear FilesTouched to simulate a conversation-only session
+ idleState.FilesTouched = nil
+ // CheckpointTranscriptStart=0 so sessionHasNewContent returns true
+ idleState.CheckpointTranscriptStart = 0
+ require.NoError(t, s.saveSessionState(context.Background(), idleState))
+
+ idleOriginalStepCount := idleState.StepCount
+
+ // --- Make a commit with an unrelated file ---
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "other-work.txt"), []byte("other work"), 0o644))
+ _, err = wt.Add("other-work.txt")
+ require.NoError(t, err)
+
+ cpID := "f1f2f3f4f5f6"
+ commitMsg := "other work\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // --- Verify: IDLE session with no files was NOT condensed ---
+ idleState, err = s.loadSessionState(context.Background(), idleSessionID)
+ require.NoError(t, err)
+
+ assert.Equal(t, idleOriginalStepCount, idleState.StepCount,
+ "IDLE session with empty FilesTouched should NOT be condensed")
+ assert.Equal(t, session.PhaseIdle, idleState.Phase,
+ "IDLE session should remain IDLE")
+ // BaseCommit is NOT updated for non-ACTIVE sessions (updateBaseCommitIfChanged skips them)
+}
+
+// TestPostCommit_IdleSession_NoTranscriptFallbackForCarryForward verifies that
+// carry-forward computation for IDLE sessions does NOT fall back to transcript
+// extraction. Only ACTIVE sessions (mid-session commits before Stop) should parse
+// the transcript, because IDLE sessions have FilesTouched populated by SaveStep.
+//
+// Regression test: resolveFilesTouched unconditionally falls back to transcript
+// extraction, but the PostCommit call site must gate it on IsActive().
+func TestPostCommit_IdleSession_NoTranscriptFallbackForCarryForward(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // Create an IDLE session with checkpoint
+ sessionID := "idle-transcript-guard"
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseIdle
+ // Clear FilesTouched to simulate the edge case
+ state.FilesTouched = nil
+ // Set transcript info so transcript extraction WOULD find files if called
+ state.AgentType = agent.AgentTypeGemini
+ transcriptPath := filepath.Join(dir, "idle-transcript.json")
+ transcript := `{
+ "messages": [
+ {"type": "user", "content": [{"text": "create file"}]},
+ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "` + filepath.Join(dir, "test.txt") + `"}}]}
+ ]
+}`
+ require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
+ state.TranscriptPath = transcriptPath
+ state.CheckpointTranscriptStart = 0
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ originalStepCount := state.StepCount
+
+ // Commit the file the transcript references — if transcript extraction
+ // ran, it would find overlap and trigger condensation
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("committed"), 0o644))
+ _, err = wt.Add("test.txt")
+ require.NoError(t, err)
+
+ cpID := "a1a2a3a4a5a6"
+ commitMsg := "commit test.txt\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify: IDLE session was NOT condensed (transcript fallback was skipped)
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.Equal(t, originalStepCount, state.StepCount,
+ "IDLE session should NOT be condensed via transcript fallback — only ACTIVE sessions get transcript extraction for carry-forward")
+}
+
+// TestPostCommit_IdleSession_SkipsSentinelWait is a regression test verifying that
+// PostCommit for an IDLE session with AgentType=ClaudeCode and a TranscriptPath
+// completes quickly without hitting the 3s sentinel timeout in PrepareTranscript.
+//
+// Before the fix, the transcript extraction functions called PrepareTranscript unconditionally,
+// which triggered waitForTranscriptFlush (3s timeout) even for idle/ended sessions
+// where the transcript was already fully flushed.
+//
+// After the fix, PrepareTranscript is only called when state.Phase.IsActive().
+func TestPostCommit_IdleSession_SkipsSentinelWait(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-idle-skip-sentinel"
+
+ // Initialize session and save a checkpoint
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to IDLE, set AgentType to Claude Code, and set TranscriptPath
+ // Without TranscriptPath, the PrepareTranscript code path is never reached.
+ // Without AgentType=ClaudeCode, the sentinel wait is not triggered.
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseIdle
+ state.LastInteractionTime = nil
+ state.FilesTouched = []string{"test.txt"}
+ state.AgentType = agent.AgentTypeClaudeCode
+
+ // Create a transcript file so PrepareTranscript would be triggered if not guarded
+ transcriptFile := filepath.Join(dir, ".trace", "transcript-"+sessionID+".jsonl")
+ require.NoError(t, os.MkdirAll(filepath.Dir(transcriptFile), 0o755))
+ require.NoError(t, os.WriteFile(transcriptFile, []byte(`{"type":"human"}`+"\n"), 0o644))
+ state.TranscriptPath = transcriptFile
+
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Create a commit WITH the Trace-Checkpoint trailer
+ commitWithCheckpointTrailer(t, repo, dir, "a1a2a3a4a5a6")
+
+ // Time PostCommit — before the fix this would take ~3s+ due to sentinel timeout
+ start := time.Now()
+ err = s.PostCommit(context.Background())
+ elapsed := time.Since(start)
+ require.NoError(t, err)
+
+ // Assert it completes well under the 3s sentinel timeout.
+ // Normal PostCommit for these tests runs in <500ms (git operations only).
+ assert.Less(t, elapsed, 2*time.Second,
+ "IDLE session PostCommit should skip sentinel wait and complete in <2s, took %v", elapsed)
+
+ // Verify condensation still happened correctly
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err, "trace/checkpoints/v1 branch should exist after condensation")
+ assert.NotNil(t, sessionsRef)
+}
diff --git a/cli/strategy/phase_postcommit_4_test.go b/cli/strategy/phase_postcommit_4_test.go
new file mode 100644
index 0000000..351e33f
--- /dev/null
+++ b/cli/strategy/phase_postcommit_4_test.go
@@ -0,0 +1,521 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/session"
+ "github.com/GrayCodeAI/trace/cli/trailers"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPostCommit_EndedSession_SkipsSentinelWait is the same regression test as
+// TestPostCommit_IdleSession_SkipsSentinelWait but for ENDED phase sessions.
+// Both IDLE and ENDED sessions should skip the sentinel wait since their
+// transcripts are already fully flushed.
+func TestPostCommit_EndedSession_SkipsSentinelWait(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-ended-skip-sentinel"
+
+ // Initialize session and save a checkpoint
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to ENDED, set AgentType to Claude Code, and set TranscriptPath
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ now := time.Now()
+ state.Phase = session.PhaseEnded
+ state.EndedAt = &now
+ state.FilesTouched = []string{"test.txt"}
+ state.AgentType = agent.AgentTypeClaudeCode
+
+ // Create a transcript file so PrepareTranscript would be triggered if not guarded
+ transcriptFile := filepath.Join(dir, ".trace", "transcript-"+sessionID+".jsonl")
+ require.NoError(t, os.MkdirAll(filepath.Dir(transcriptFile), 0o755))
+ require.NoError(t, os.WriteFile(transcriptFile, []byte(`{"type":"human"}`+"\n"), 0o644))
+ state.TranscriptPath = transcriptFile
+
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Create a commit WITH the Trace-Checkpoint trailer
+ commitWithCheckpointTrailer(t, repo, dir, "e1e2e3e4e5e6")
+
+ // Time PostCommit — before the fix this would take ~3s+ due to sentinel timeout
+ start := time.Now()
+ err = s.PostCommit(context.Background())
+ elapsed := time.Since(start)
+ require.NoError(t, err)
+
+ // Assert it completes well under the 3s sentinel timeout
+ assert.Less(t, elapsed, 2*time.Second,
+ "ENDED session PostCommit should skip sentinel wait and complete in <2s, took %v", elapsed)
+
+ // Verify condensation still happened correctly
+ sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err, "trace/checkpoints/v1 branch should exist after condensation")
+ assert.NotNil(t, sessionsRef)
+}
+
+// TestPostCommit_EndedSession_SetsFullyCondensed verifies that an ENDED session
+// is marked FullyCondensed after condensation when no carry-forward files remain.
+func TestPostCommit_EndedSession_SetsFullyCondensed(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-postcommit-ended-fully-condensed"
+
+ // Initialize session and save a checkpoint
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to ENDED with files touched (the committed file matches shadow branch)
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ now := time.Now()
+ state.Phase = session.PhaseEnded
+ state.EndedAt = &now
+ state.FilesTouched = []string{"test.txt"}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Create a commit that includes test.txt — this commits the only touched file,
+ // so carry-forward will be empty afterward.
+ commitWithCheckpointTrailer(t, repo, dir, "fc01fc01fc01")
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify FullyCondensed is set
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.True(t, state.FullyCondensed,
+ "ENDED session with no carry-forward should be marked FullyCondensed")
+ assert.Equal(t, session.PhaseEnded, state.Phase)
+ assert.Empty(t, state.FilesTouched,
+ "FilesTouched should be empty after all files were committed")
+}
+
+// TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit verifies that
+// a FullyCondensed ENDED session is skipped entirely on subsequent commits,
+// avoiding redundant shadow branch resolution and condensation attempts.
+func TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-postcommit-skip-fully-condensed"
+
+ // Initialize session and save a checkpoint
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ // Set phase to ENDED with files touched
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ now := time.Now()
+ state.Phase = session.PhaseEnded
+ state.EndedAt = &now
+ state.FilesTouched = []string{"test.txt"}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // First commit — condenses the ENDED session and marks it FullyCondensed
+ commitWithCheckpointTrailer(t, repo, dir, "fc02fc02fc02")
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify it's now fully condensed
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ require.True(t, state.FullyCondensed)
+
+ // Record the LastCheckpointID — this should persist (the reason the session exists)
+ lastCPID := state.LastCheckpointID
+
+ // Second commit — the fully-condensed session should be skipped entirely.
+ // Create a new file so there's something to commit.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "other.txt"), []byte("other"), 0o644))
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("other.txt")
+ require.NoError(t, err)
+ commitMsg := "second commit\n\n" + trailers.CheckpointTrailerKey + ": fc03fc03fc03\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{
+ Name: "Test",
+ Email: "test@test.com",
+ When: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // Run PostCommit again
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify state is unchanged — the session was skipped, not re-processed
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.True(t, state.FullyCondensed,
+ "FullyCondensed should still be true after being skipped")
+ assert.Equal(t, session.PhaseEnded, state.Phase)
+ assert.Equal(t, lastCPID, state.LastCheckpointID,
+ "LastCheckpointID should be preserved across skipped commits")
+}
+
+// TestPostCommit_NonEndedSession_NotMarkedFullyCondensed verifies that ACTIVE
+// and IDLE sessions are never marked FullyCondensed, even when condensed with
+// no carry-forward. Only ENDED sessions get the flag.
+func TestPostCommit_NonEndedSession_NotMarkedFullyCondensed(t *testing.T) {
+ for _, phase := range []session.Phase{session.PhaseActive, session.PhaseIdle} {
+ t.Run(string(phase), func(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-postcommit-" + string(phase) + "-not-fully-condensed"
+
+ // Initialize session and save a checkpoint
+ setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
+
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = phase
+ state.FilesTouched = []string{"test.txt"}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // Commit the file
+ commitWithCheckpointTrailer(t, repo, dir, "fc04fc04fc04")
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify FullyCondensed is NOT set
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ assert.False(t, state.FullyCondensed,
+ "%s sessions must never be marked FullyCondensed", phase)
+ })
+ }
+}
+
+// TestPostCommit_ActiveSession_DifferentFilesThanCommit_ShouldCondense verifies
+// that when an ACTIVE session's Turn 1 touched file A (e.g., a cache file) but
+// Turn 2 commits different files B and C, condensation still happens.
+//
+// This is a regression test for the bug where shouldCondenseWithOverlapCheck
+// incorrectly skipped condensation because filesTouchedBefore (from Turn 1)
+// didn't overlap with the committed files (from Turn 2). ACTIVE sessions with a
+// recent LastInteractionTime should condense when hasNew is true — the overlap
+// check is only meaningful for IDLE/ENDED sessions and stale ACTIVE sessions.
+func TestPostCommit_ActiveSession_DifferentFilesThanCommit_ShouldCondense(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ sessionID := "test-active-different-files"
+
+ // --- Turn 1: Save checkpoint touching a cache file (not what will be committed) ---
+ // Write the cache file so SaveStep can snapshot it
+ cacheFile := filepath.Join(dir, ".gitstats_cache.sqlite3")
+ require.NoError(t, os.WriteFile(cacheFile, []byte("cache data"), 0o644))
+
+ metadataDir := ".trace/metadata/" + sessionID
+ metadataDirAbs := filepath.Join(dir, metadataDir)
+ require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
+
+ transcript := `{"type":"human","message":{"content":"analyze git stats"}}
+{"type":"assistant","message":{"content":"analyzing stats, creating cache"}}
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(metadataDirAbs, paths.TranscriptFileName),
+ []byte(transcript), 0o644,
+ ))
+
+ err = s.SaveStep(context.Background(), StepContext{
+ SessionID: sessionID,
+ ModifiedFiles: []string{},
+ NewFiles: []string{".gitstats_cache.sqlite3"},
+ DeletedFiles: []string{},
+ MetadataDir: metadataDir,
+ MetadataDirAbs: metadataDirAbs,
+ CommitMessage: "Checkpoint: cache created",
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+
+ // Set phase to ACTIVE (agent mid-turn) with recent interaction
+ state, err := s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+ state.Phase = session.PhaseActive
+ // FilesTouched reflects Turn 1's cache file — NOT the files about to be committed
+ state.FilesTouched = []string{".gitstats_cache.sqlite3"}
+ now := time.Now()
+ state.LastInteractionTime = &now
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ // --- Turn 2: Agent commits DIFFERENT files (README.md, org_commit_activity.py) ---
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Git Stats"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "org_commit_activity.py"), []byte("print('hello')"), 0o644))
+
+ wt, err := repo.Worktree()
+ require.NoError(t, err)
+ _, err = wt.Add("README.md")
+ require.NoError(t, err)
+ _, err = wt.Add("org_commit_activity.py")
+ require.NoError(t, err)
+
+ cpID := "d1d2d3d4d5d6"
+ commitMsg := "Add git stats tools\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
+ _, err = wt.Commit(commitMsg, &git.CommitOptions{
+ Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
+ })
+ require.NoError(t, err)
+
+ // --- Run PostCommit ---
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // --- Verify condensation happened ---
+ state, err = s.loadSessionState(context.Background(), sessionID)
+ require.NoError(t, err)
+
+ // StepCount should be 1 because carry-forward created a new checkpoint for
+ // .gitstats_cache.sqlite3 which was NOT committed (remaining agent work)
+ assert.Equal(t, 1, state.StepCount,
+ "ACTIVE session StepCount should be 1 (carry-forward for uncommitted cache file)")
+
+ // Phase stays ACTIVE
+ assert.Equal(t, session.PhaseActive, state.Phase,
+ "ACTIVE session should stay ACTIVE after condensation")
+
+ // trace/checkpoints/v1 branch should exist
+ _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
+ require.NoError(t, err,
+ "trace/checkpoints/v1 should exist — ACTIVE session with different files must still condense")
+}
+
+// TestPostCommit_EmptyEndedSession_MarkedFullyCondensed verifies that an ENDED
+// session with no FilesTouched and no new content (hasNew=false) is marked
+// FullyCondensed on the next PostCommit. Without this, empty ENDED sessions
+// go through HandleDiscardIfNoFiles (which is a no-op for ENDED) and are
+// iterated on every future PostCommit forever.
+func TestPostCommit_EmptyEndedSession_MarkedFullyCondensed(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+
+ // We need a real session with BaseCommit/WorktreeID to pass PostCommit's
+ // session iteration. Use setupSessionWithCheckpoint to create the plumbing,
+ // then create a separate empty ENDED session sharing the same base commit.
+ helperSessionID := "helper-session"
+ setupSessionWithCheckpoint(t, s, repo, dir, helperSessionID)
+
+ helperState, err := s.loadSessionState(context.Background(), helperSessionID)
+ require.NoError(t, err)
+
+ // Create the empty ENDED session — no files, no steps, no shadow branch content
+ emptySessionID := "empty-ended-session"
+ endedAt := time.Now().Add(-2 * time.Hour)
+ emptyState := &SessionState{
+ SessionID: emptySessionID,
+ BaseCommit: helperState.BaseCommit,
+ WorktreePath: helperState.WorktreePath,
+ WorktreeID: helperState.WorktreeID,
+ StartedAt: time.Now().Add(-3 * time.Hour),
+ Phase: session.PhaseEnded,
+ EndedAt: &endedAt,
+ FilesTouched: nil,
+ StepCount: 0,
+ }
+ require.NoError(t, s.saveSessionState(context.Background(), emptyState))
+
+ // Create a commit with checkpoint trailer
+ commitWithCheckpointTrailer(t, repo, dir, "e1e2e3e4e5e6")
+
+ // Run PostCommit
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ // Verify: empty ENDED session should be marked FullyCondensed
+ state, err := s.loadSessionState(context.Background(), emptySessionID)
+ require.NoError(t, err)
+ require.NotNil(t, state)
+ assert.True(t, state.FullyCondensed,
+ "ENDED session with no files and no new content should be marked FullyCondensed")
+ assert.Equal(t, session.PhaseEnded, state.Phase,
+ "Phase should stay ENDED")
+}
+
+// TestCountWarnableStaleEndedSessions verifies that the warning only counts the
+// same ENDED sessions that 'trace doctor' can actually condense.
+// Uses t.Chdir — do NOT add t.Parallel().
+func TestCountWarnableStaleEndedSessions(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ setupSessionWithCheckpoint(t, s, repo, dir, "warnable-session")
+
+ warnableState, err := s.loadSessionState(context.Background(), "warnable-session")
+ require.NoError(t, err)
+ warnableState.Phase = session.PhaseEnded
+ warnableState.FullyCondensed = false
+ require.NoError(t, s.saveSessionState(context.Background(), warnableState))
+
+ sessions := []*SessionState{
+ warnableState,
+ {
+ SessionID: "no-shadow-branch",
+ BaseCommit: "1234567890abcdef1234567890abcdef12345678",
+ WorktreeID: warnableState.WorktreeID,
+ Phase: session.PhaseEnded,
+ FullyCondensed: false,
+ StepCount: 3,
+ },
+ {
+ SessionID: "zero-steps",
+ BaseCommit: warnableState.BaseCommit,
+ WorktreeID: warnableState.WorktreeID,
+ Phase: session.PhaseEnded,
+ FullyCondensed: false,
+ StepCount: 0,
+ },
+ {
+ SessionID: "fully-condensed",
+ BaseCommit: warnableState.BaseCommit,
+ WorktreeID: warnableState.WorktreeID,
+ Phase: session.PhaseEnded,
+ FullyCondensed: true,
+ StepCount: 3,
+ },
+ {
+ SessionID: "idle-session",
+ BaseCommit: warnableState.BaseCommit,
+ WorktreeID: warnableState.WorktreeID,
+ Phase: session.PhaseIdle,
+ FullyCondensed: false,
+ StepCount: 3,
+ },
+ }
+
+ assert.Equal(t, 1, countWarnableStaleEndedSessions(repo, sessions))
+}
+
+// TestPostCommit_WarnStaleEndedSessions_AfterProcessing verifies that the
+// warning is emitted only for sessions that remain stale AFTER the current
+// commit is processed.
+// Uses t.Chdir — do NOT add t.Parallel().
+func TestPostCommit_WarnStaleEndedSessions_AfterProcessing(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+
+ repo, err := git.PlainOpen(dir)
+ require.NoError(t, err)
+
+ s := &ManualCommitStrategy{}
+ type sessionFile struct {
+ sessionID string
+ fileName string
+ }
+ sessionFiles := []sessionFile{
+ {"ended-a", "stale-a.txt"},
+ {"ended-b", "stale-b.txt"},
+ {"ended-c", "stale-c.txt"},
+ }
+
+ filesToCommit := make([]string, 0, len(sessionFiles))
+ for _, sf := range sessionFiles {
+ setupSessionWithCheckpointAndFile(t, s, dir, sf.sessionID, sf.fileName)
+
+ state, loadErr := s.loadSessionState(context.Background(), sf.sessionID)
+ require.NoError(t, loadErr)
+ now := time.Now()
+ state.Phase = session.PhaseEnded
+ state.EndedAt = &now
+ state.FilesTouched = []string{sf.fileName}
+ require.NoError(t, s.saveSessionState(context.Background(), state))
+
+ filesToCommit = append(filesToCommit, sf.fileName)
+ }
+
+ commitFilesWithTrailer(t, repo, dir, "abc123def456", filesToCommit...)
+
+ // Capture warning output via the injectable stderrWriter instead of
+ // mutating the process-global os.Stderr.
+ var buf bytes.Buffer
+ oldWriter := stderrWriter
+ stderrWriter = &buf
+ defer func() { stderrWriter = oldWriter }()
+
+ err = s.PostCommit(context.Background())
+ require.NoError(t, err)
+
+ assert.NotContains(t, buf.String(), "trace doctor",
+ "warning should be suppressed when this commit already condensed the stale ended sessions")
+}
+
+// TestWarnStaleEndedSessions_RateLimit verifies the 24h sentinel file gate.
+// Uses t.Chdir — do NOT add t.Parallel().
+func TestWarnStaleEndedSessions_RateLimit(t *testing.T) {
+ dir := setupGitRepo(t)
+ t.Chdir(dir)
+ ctx := context.Background()
+
+ // First call: no sentinel file → should write to stderr
+ var buf bytes.Buffer
+ warnStaleEndedSessionsTo(ctx, 5, &buf)
+ assert.Contains(t, buf.String(), "trace doctor")
+
+ // Sentinel file now exists with current mtime → second call suppressed
+ buf.Reset()
+ warnStaleEndedSessionsTo(ctx, 5, &buf)
+ assert.Empty(t, buf.String(), "second call within window must be suppressed")
+
+ // Backdate sentinel file by 25h → call should warn again
+ commonDir, err := GetGitCommonDir(ctx)
+ require.NoError(t, err)
+ warnFile := filepath.Join(commonDir, session.SessionStateDirName, staleEndedSessionWarnFile)
+ past := time.Now().Add(-25 * time.Hour)
+ require.NoError(t, os.Chtimes(warnFile, past, past))
+
+ buf.Reset()
+ warnStaleEndedSessionsTo(ctx, 5, &buf)
+ assert.Contains(t, buf.String(), "trace doctor")
+}
diff --git a/cli/strategy/phase_postcommit_test.go b/cli/strategy/phase_postcommit_test.go
index 9d0929c..85dadf0 100644
--- a/cli/strategy/phase_postcommit_test.go
+++ b/cli/strategy/phase_postcommit_test.go
@@ -1,24 +1,17 @@
package strategy
import (
- "bytes"
"context"
"os"
"path/filepath"
"testing"
"time"
- "github.com/GrayCodeAI/trace/cli/agent"
- "github.com/GrayCodeAI/trace/cli/agent/types"
- "github.com/GrayCodeAI/trace/cli/checkpoint"
- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/session"
- "github.com/GrayCodeAI/trace/cli/trailers"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
- "github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -712,2044 +705,3 @@ func TestTurnEnd_Active_NoActions(t *testing.T) {
assert.NoError(t, err,
"shadow branch should still exist after no-op turn end")
}
-
-// TestPostCommit_FilesTouched_ResetsAfterCondensation verifies that FilesTouched
-// is reset after condensation, so subsequent condensations only contain the files
-// touched since the last commit — not the accumulated history.
-func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-filestouched-reset"
-
- // --- Round 1: Save checkpoint touching files A.txt and B.txt ---
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"round 1 prompt"}}
-{"type":"assistant","message":{"content":"round 1 response"}}
-`
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(transcript), 0o644,
- ))
-
- // Create files A.txt and B.txt
- require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{"A.txt", "B.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1: files A and B",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Set phase to IDLE so PostCommit triggers immediate condensation
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseIdle
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Verify FilesTouched has A.txt and B.txt before condensation
- assert.ElementsMatch(t, []string{"A.txt", "B.txt"}, state.FilesTouched,
- "FilesTouched should contain A.txt and B.txt before first condensation")
-
- // --- Commit A.txt, B.txt and condense (round 1) ---
- checkpointID1 := "a1a2a3a4a5a6"
- commitFilesWithTrailer(t, repo, dir, checkpointID1, "A.txt", "B.txt")
-
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify condensation happened
- _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err, "trace/checkpoints/v1 should exist after first condensation")
-
- // Verify first condensation contains A.txt and B.txt
- store := checkpoint.NewGitStore(repo)
- cpID1 := id.MustCheckpointID(checkpointID1)
- summary1, err := store.ReadCommitted(context.Background(), cpID1)
- require.NoError(t, err)
- require.NotNil(t, summary1)
- assert.ElementsMatch(t, []string{"A.txt", "B.txt"}, summary1.FilesTouched,
- "First condensation should contain A.txt and B.txt")
-
- // Verify FilesTouched was reset after condensation
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Nil(t, state.FilesTouched,
- "FilesTouched should be nil after condensation (all files were committed)")
-
- // --- Round 2: Save checkpoint touching files C.txt and D.txt ---
-
- // Append to transcript for round 2
- transcript2 := `{"type":"human","message":{"content":"round 2 prompt"}}
-{"type":"assistant","message":{"content":"round 2 response"}}
-`
- f, err := os.OpenFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- os.O_APPEND|os.O_WRONLY, 0o644,
- )
- require.NoError(t, err)
- _, err = f.WriteString(transcript2)
- require.NoError(t, err)
- require.NoError(t, f.Close())
-
- // Create files C.txt and D.txt
- require.NoError(t, os.WriteFile(filepath.Join(dir, "C.txt"), []byte("file C"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "D.txt"), []byte("file D"), 0o644))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{"C.txt", "D.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2: files C and D",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Set phase to IDLE for immediate condensation
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseIdle
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Verify FilesTouched only has C.txt and D.txt (NOT A.txt, B.txt)
- assert.ElementsMatch(t, []string{"C.txt", "D.txt"}, state.FilesTouched,
- "FilesTouched should only contain C.txt and D.txt after reset")
-
- // --- Commit C.txt, D.txt and condense (round 2) ---
- checkpointID2 := "b1b2b3b4b5b6"
- commitFilesWithTrailer(t, repo, dir, checkpointID2, "C.txt", "D.txt")
-
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify second condensation contains ONLY C.txt and D.txt
- cpID2 := id.MustCheckpointID(checkpointID2)
- summary2, err := store.ReadCommitted(context.Background(), cpID2)
- require.NoError(t, err)
- require.NotNil(t, summary2, "Second condensation should exist")
- assert.ElementsMatch(t, []string{"C.txt", "D.txt"}, summary2.FilesTouched,
- "Second condensation should only contain C.txt and D.txt, not accumulated files from first condensation")
-}
-
-// TestSubtractFiles verifies that subtractFiles correctly removes files present
-// in the exclude set and preserves files not in it.
-func TestSubtractFiles(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- files []string
- exclude map[string]struct{}
- expected []string
- }{
- {
- name: "no overlap",
- files: []string{"a.txt", "b.txt"},
- exclude: map[string]struct{}{"c.txt": {}},
- expected: []string{"a.txt", "b.txt"},
- },
- {
- name: "full overlap",
- files: []string{"a.txt", "b.txt"},
- exclude: map[string]struct{}{"a.txt": {}, "b.txt": {}},
- expected: nil,
- },
- {
- name: "partial overlap",
- files: []string{"a.txt", "b.txt", "c.txt"},
- exclude: map[string]struct{}{"b.txt": {}},
- expected: []string{"a.txt", "c.txt"},
- },
- {
- name: "empty files",
- files: []string{},
- exclude: map[string]struct{}{"a.txt": {}},
- expected: nil,
- },
- {
- name: "empty exclude",
- files: []string{"a.txt", "b.txt"},
- exclude: map[string]struct{}{},
- expected: []string{"a.txt", "b.txt"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- result := subtractFiles(tt.files, tt.exclude)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
-// TestFilesChangedInCommit verifies that filesChangedInCommit correctly extracts
-// the set of files changed in a commit by diffing against its parent.
-func TestFilesChangedInCommit(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- // Create files and commit them
- require.NoError(t, os.WriteFile(filepath.Join(dir, "file1.txt"), []byte("content1"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "file2.txt"), []byte("content2"), 0o644))
- _, err = wt.Add("file1.txt")
- require.NoError(t, err)
- _, err = wt.Add("file2.txt")
- require.NoError(t, err)
-
- commitHash, err := wt.Commit("add files", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- commit, err := repo.CommitObject(commitHash)
- require.NoError(t, err)
-
- headTree, err := commit.Tree()
- require.NoError(t, err)
- var parentTree *object.Tree
- if commit.NumParents() > 0 {
- parent, pErr := commit.Parent(0)
- require.NoError(t, pErr)
- parentTree, err = parent.Tree()
- require.NoError(t, err)
- }
-
- changed := filesChangedInCommit(context.Background(), dir, commit, headTree, parentTree)
- assert.Contains(t, changed, "file1.txt")
- assert.Contains(t, changed, "file2.txt")
- // test.txt was in the initial commit, not this one
- assert.NotContains(t, changed, "test.txt")
-}
-
-// TestFilesChangedInCommit_InitialCommit verifies that filesChangedInCommit
-// handles the initial commit (no parent) by listing all files.
-func TestFilesChangedInCommit_InitialCommit(t *testing.T) {
- dir := t.TempDir()
- t.Chdir(dir)
-
- repo, err := git.PlainInit(dir, false)
- require.NoError(t, err)
-
- cfg, err := repo.Config()
- require.NoError(t, err)
- cfg.User.Name = "Test"
- cfg.User.Email = "test@test.com"
- require.NoError(t, repo.SetConfig(cfg))
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("initial"), 0o644))
- _, err = wt.Add("init.txt")
- require.NoError(t, err)
-
- commitHash, err := wt.Commit("initial", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- commit, err := repo.CommitObject(commitHash)
- require.NoError(t, err)
-
- headTree, err := commit.Tree()
- require.NoError(t, err)
-
- changed := filesChangedInCommit(context.Background(), dir, commit, headTree, nil)
- assert.Contains(t, changed, "init.txt")
- assert.Len(t, changed, 1)
-}
-
-// TestFilesChangedInCommit_FallbackOnBadRepoDir verifies that when git diff-tree fails
-// (e.g. invalid repoDir), filesChangedInCommit falls back to go-git tree walk and still
-// returns correct results instead of an empty map.
-func TestFilesChangedInCommit_FallbackOnBadRepoDir(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644))
- _, err = wt.Add("new.txt")
- require.NoError(t, err)
-
- commitHash, err := wt.Commit("add new file", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- commit, err := repo.CommitObject(commitHash)
- require.NoError(t, err)
-
- headTree, err := commit.Tree()
- require.NoError(t, err)
- var parentTree *object.Tree
- if commit.NumParents() > 0 {
- parent, pErr := commit.Parent(0)
- require.NoError(t, pErr)
- parentTree, err = parent.Tree()
- require.NoError(t, err)
- }
-
- // Pass a bogus repoDir to force git diff-tree to fail, triggering the fallback
- changed := filesChangedInCommit(context.Background(), "/nonexistent/repo", commit, headTree, parentTree)
-
- // Fallback should still detect the changed file via go-git tree walk
- assert.Contains(t, changed, "new.txt")
- assert.NotEmpty(t, changed, "fallback should return files, not empty map")
-}
-
-// TestPostCommit_ActiveSession_CarryForward_PartialCommit verifies that when an
-// ACTIVE session has touched files A, B, C but only A and B are committed, the
-// remaining file C is carried forward to a new shadow branch.
-func TestPostCommit_ActiveSession_CarryForward_PartialCommit(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-carry-forward-partial"
-
- // Create metadata directory with transcript
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"create files A B C"}}
-{"type":"assistant","message":{"content":"creating files"}}
-`
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(transcript), 0o644,
- ))
-
- // Create all three files
- require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "C.txt"), []byte("file C"), 0o644))
-
- // Save checkpoint with all three files
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{"A.txt", "B.txt", "C.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint: files A, B, C",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Set phase to ACTIVE (agent mid-turn)
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Verify FilesTouched contains all three files
- assert.ElementsMatch(t, []string{"A.txt", "B.txt", "C.txt"}, state.FilesTouched)
-
- // Commit ONLY A.txt and B.txt (not C.txt) with checkpoint trailer
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("A.txt")
- require.NoError(t, err)
- _, err = wt.Add("B.txt")
- require.NoError(t, err)
-
- cpID := "cf1cf2cf3cf4"
- commitMsg := "commit A and B\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify session stayed ACTIVE
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Equal(t, session.PhaseActive, state.Phase)
-
- // Verify carry-forward: FilesTouched should now only contain C.txt
- assert.Equal(t, []string{"C.txt"}, state.FilesTouched,
- "carry-forward should preserve only the uncommitted file C.txt")
-
- // Verify StepCount was set to 1 (carry-forward creates a new checkpoint)
- assert.Equal(t, 1, state.StepCount,
- "carry-forward should set StepCount to 1")
-
- // Verify CheckpointTranscriptStart was reset to 0 (prompt-level carry-forward)
- assert.Equal(t, 0, state.CheckpointTranscriptStart,
- "carry-forward should reset CheckpointTranscriptStart to 0 for full transcript reprocessing")
-
- // Verify LastCheckpointID was cleared (next commit generates fresh ID)
- assert.Empty(t, state.LastCheckpointID,
- "carry-forward should clear LastCheckpointID")
-
- // Verify a new shadow branch exists at the new HEAD
- newShadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- _, err = repo.Reference(plumbing.NewBranchReferenceName(newShadowBranch), true)
- assert.NoError(t, err,
- "carry-forward should create a new shadow branch at the new HEAD")
-}
-
-// TestPostCommit_ActiveSession_CarryForward_AllCommitted verifies that when an
-// ACTIVE session's files are ALL included in the commit, no carry-forward occurs.
-func TestPostCommit_ActiveSession_CarryForward_AllCommitted(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-carry-forward-all"
-
- // Initialize session and save a checkpoint with files A and B
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"create files A B"}}
-{"type":"assistant","message":{"content":"creating files"}}
-`
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(transcript), 0o644,
- ))
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "A.txt"), []byte("file A"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "B.txt"), []byte("file B"), 0o644))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{"A.txt", "B.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint: files A, B",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Set phase to ACTIVE
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Commit ALL files (A.txt and B.txt) with checkpoint trailer
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("A.txt")
- require.NoError(t, err)
- _, err = wt.Add("B.txt")
- require.NoError(t, err)
-
- cpID := "cf5cf6cf7cf8"
- commitMsg := "commit A and B\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify session stayed ACTIVE
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Equal(t, session.PhaseActive, state.Phase)
-
- // Verify NO carry-forward: FilesTouched should be nil (all condensed, nothing remaining)
- assert.Nil(t, state.FilesTouched,
- "when all files are committed, no carry-forward should occur (FilesTouched cleared by condensation)")
-
- // Verify StepCount was reset to 0 by condensation (not 1 from carry-forward)
- assert.Equal(t, 0, state.StepCount,
- "without carry-forward, StepCount should be reset to 0 by condensation")
-}
-
-// TestPostCommit_ActiveSession_RecordsTurnCheckpointIDs verifies that PostCommit
-// records the checkpoint ID in TurnCheckpointIDs for ACTIVE sessions.
-// This enables HandleTurnEnd to finalize all checkpoints with the full transcript.
-func TestPostCommit_ActiveSession_RecordsTurnCheckpointIDs(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-turn-checkpoint-ids"
-
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to ACTIVE (simulating agent mid-turn)
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- state.TurnCheckpointIDs = nil // Start clean
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Create first commit with checkpoint trailer
- commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
-
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify TurnCheckpointIDs was populated
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Equal(t, []string{"a1b2c3d4e5f6"}, state.TurnCheckpointIDs,
- "TurnCheckpointIDs should contain the checkpoint ID after condensation")
-}
-
-// TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs verifies that PostCommit
-// does NOT record TurnCheckpointIDs for IDLE sessions.
-func TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-idle-no-turn-ids"
-
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to IDLE with files touched so overlap check passes
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseIdle
- state.FilesTouched = []string{"test.txt"}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- commitWithCheckpointTrailer(t, repo, dir, "c3d4e5f6a1b2")
-
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify TurnCheckpointIDs was NOT set (IDLE sessions don't need finalization)
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Empty(t, state.TurnCheckpointIDs,
- "TurnCheckpointIDs should not be populated for IDLE sessions")
-}
-
-// TestHandleTurnEnd_PartialFailure verifies that HandleTurnEnd continues
-// processing remaining checkpoints when one UpdateCommitted call fails.
-// This locks the best-effort behavior: valid checkpoints get finalized even
-// when one checkpoint ID is invalid or missing from trace/checkpoints/v1.
-func TestHandleTurnEnd_PartialFailure(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-partial-failure"
-
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to ACTIVE and create a transcript file with updated content
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- state.TurnCheckpointIDs = nil
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // First commit → creates real checkpoint on trace/checkpoints/v1
- commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
- require.NoError(t, s.PostCommit(context.Background()))
-
- // Write new content and create a second checkpoint on the shadow branch.
- // Use SaveStep directly (instead of setupSessionWithCheckpoint) so that
- // second.txt is included in FilesTouched — the overlap check needs it.
- require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644))
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.txt"},
- NewFiles: []string{"second.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err, "SaveStep should succeed for second checkpoint")
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- // Preserve TurnCheckpointIDs from the first commit
- state.TurnCheckpointIDs = []string{"a1b2c3d4e5f6"}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- commitFilesWithTrailer(t, repo, dir, "b2c3d4e5f6a1", "second.txt")
- require.NoError(t, s.PostCommit(context.Background()))
-
- // Verify we now have 2 real checkpoint IDs
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- require.Len(t, state.TurnCheckpointIDs, 2,
- "Should have 2 real checkpoint IDs after 2 mid-turn commits")
-
- // Inject a fake 3rd checkpoint ID that doesn't exist on trace/checkpoints/v1
- state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, "ffffffffffff")
-
- // Write a full transcript file for HandleTurnEnd to read
- fullTranscript := `{"type":"human","message":{"content":"build something"}}
-{"type":"assistant","message":{"content":"done building"}}
-{"type":"human","message":{"content":"now test it"}}
-{"type":"assistant","message":{"content":"tests pass"}}
-`
- transcriptPath := filepath.Join(dir, ".trace", "metadata", sessionID, "full_transcript.jsonl")
- require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755))
- require.NoError(t, os.WriteFile(transcriptPath, []byte(fullTranscript), 0o644))
- state.TranscriptPath = transcriptPath
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Call HandleTurnEnd — should NOT return error (best-effort)
- err = s.HandleTurnEnd(context.Background(), state)
- require.NoError(t, err,
- "HandleTurnEnd should return nil even with partial failures (best-effort)")
-
- // TurnCheckpointIDs should be cleared regardless of partial failure
- assert.Empty(t, state.TurnCheckpointIDs,
- "TurnCheckpointIDs should be cleared after HandleTurnEnd, even with errors")
-
- // Verify the 2 valid checkpoints were finalized with the full transcript
- store := checkpoint.NewGitStore(repo)
- for _, cpIDStr := range []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"} {
- cpID := id.MustCheckpointID(cpIDStr)
- content, readErr := store.ReadSessionContent(context.Background(), cpID, 0)
- require.NoError(t, readErr,
- "Should be able to read finalized checkpoint %s", cpIDStr)
- assert.Contains(t, string(content.Transcript), "now test it",
- "Checkpoint %s should contain the full transcript (including later messages)", cpIDStr)
- }
-}
-
-func TestHandleTurnEnd_V2FullCurrent_PreservesTaskMetadata(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- // Enable checkpoints_v2 dual-write so PostCommit/HandleTurnEnd update v2 refs.
- traceDir := filepath.Join(dir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- s := &ManualCommitStrategy{}
- sessionID := "test-turn-end-v2-task-preserve"
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
- transcriptPath := filepath.Join(metadataDirAbs, paths.TranscriptFileName)
- require.NoError(t, os.WriteFile(transcriptPath, []byte(testTranscriptPromptResponse), 0o644))
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("agent modified content"), 0o644))
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.txt"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- subagentTranscriptPath := filepath.Join(metadataDirAbs, "subagent.jsonl")
- require.NoError(t, os.WriteFile(subagentTranscriptPath, []byte("{\"type\":\"event\",\"message\":\"done\"}\n"), 0o644))
- err = s.SaveTaskStep(context.Background(), TaskStepContext{
- SessionID: sessionID,
- ToolUseID: "toolu_01TASK",
- AgentID: "agent-01",
- ModifiedFiles: []string{"test.txt"},
- TranscriptPath: transcriptPath,
- SubagentTranscriptPath: subagentTranscriptPath,
- CheckpointUUID: "uuid-task-001",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- SubagentType: "general",
- TaskDescription: "Implement task",
- AgentType: agent.AgentTypeClaudeCode,
- })
- require.NoError(t, err)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- state.TurnCheckpointIDs = nil
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- cpID := "a1b2c3d4e5f6"
- commitWithCheckpointTrailer(t, repo, dir, cpID)
- require.NoError(t, s.PostCommit(context.Background()))
-
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- require.Equal(t, []string{cpID}, state.TurnCheckpointIDs)
-
- fullTranscript := `{"type":"human","message":{"content":"final user prompt"}}
-{"type":"assistant","message":{"content":"final assistant response"}}
-`
- require.NoError(t, os.WriteFile(transcriptPath, []byte(fullTranscript), 0o644))
- state.TranscriptPath = transcriptPath
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- require.NoError(t, s.HandleTurnEnd(context.Background(), state))
-
- v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true)
- require.NoError(t, err)
- v2FullCommit, err := repo.CommitObject(v2FullRef.Hash())
- require.NoError(t, err)
- v2FullTree, err := v2FullCommit.Tree()
- require.NoError(t, err)
-
- checkpointID := id.MustCheckpointID(cpID)
- _, err = v2FullTree.File(checkpointID.Path() + "/0/tasks/toolu_01TASK/checkpoint.json")
- require.NoError(t, err, "task metadata should be preserved after HandleTurnEnd finalization")
-}
-
-func TestHandleTurnEnd_V2UsesExternalTranscriptCompactor(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(dir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- agentName := types.AgentName("test-external-turn-end-compactor")
- agentType := types.AgentType("Test External Turn End Compactor")
- fakeAgent := &fakeTranscriptCompactorAgent{
- name: agentName,
- agentType: agentType,
- fullCompact: []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"initial\"}\n"),
- caps: agent.DeclaredCaps{CompactTranscript: true},
- }
- agent.Register(agentName, func() agent.Agent { return fakeAgent })
-
- s := &ManualCommitStrategy{}
- sessionID := "test-turn-end-external-compactor"
-
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.AgentType = agentType
- state.Phase = session.PhaseActive
- state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
- state.TurnCheckpointIDs = nil
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- cpIDStr := testTrailerCheckpointID.String()
- commitWithCheckpointTrailer(t, repo, dir, cpIDStr)
- require.NoError(t, s.PostCommit(context.Background()))
-
- cpID := testTrailerCheckpointID
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- initialCompact, err := v2Store.ReadSessionCompactTranscript(context.Background(), cpID, 0)
- require.NoError(t, err)
- require.Equal(t, fakeAgent.fullCompact, initialCompact)
-
- updatedTranscript := `{"type":"human","message":{"content":"build something"}}
-{"type":"assistant","message":{"content":"done building"}}
-{"type":"human","message":{"content":"now finalize it"}}
-{"type":"assistant","message":{"content":"all done"}}
-`
- require.NoError(t, os.WriteFile(state.TranscriptPath, []byte(updatedTranscript), 0o644))
- fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"final\"}\n")
-
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- require.Equal(t, []string{cpIDStr}, state.TurnCheckpointIDs)
-
- require.NoError(t, s.HandleTurnEnd(context.Background(), state))
-
- finalCompact, err := v2Store.ReadSessionCompactTranscript(context.Background(), cpID, 0)
- require.NoError(t, err)
- require.Equal(t, fakeAgent.fullCompact, finalCompact)
-}
-
-func TestHandleTurnEnd_V2ExternalTranscriptCompactor_UpdatesAllTurnCheckpoints(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- require.NoError(t, os.MkdirAll(filepath.Join(dir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(testCheckpointsV2SettingsJSON), 0o644))
-
- agentName := types.AgentName("test-external-turn-end-multi-compactor")
- agentType := types.AgentType("Test External Turn End Multi Compactor")
- fakeAgent := &fakeTranscriptCompactorAgent{
- name: agentName,
- agentType: agentType,
- fullCompact: []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-1\"}\n"),
- caps: agent.DeclaredCaps{CompactTranscript: true},
- }
- agent.Register(agentName, func() agent.Agent { return fakeAgent })
-
- s := &ManualCommitStrategy{}
- sessionID := "test-turn-end-external-nonzero-offset"
-
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.AgentType = agentType
- state.Phase = session.PhaseActive
- state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
- state.TurnCheckpointIDs = nil
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- cpID1 := "a1b2c3d4e5f6"
- commitWithCheckpointTrailer(t, repo, dir, cpID1)
- require.NoError(t, s.PostCommit(context.Background()))
-
- fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-2\"}\n")
-
- require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644))
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.txt"},
- NewFiles: []string{"second.txt"},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 2",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.AgentType = agentType
- state.Phase = session.PhaseActive
- state.TranscriptPath = filepath.Join(dir, ".trace", "metadata", sessionID, paths.TranscriptFileName)
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- cpID2 := "b2c3d4e5f6a1"
- commitFilesWithTrailer(t, repo, dir, cpID2, "second.txt")
- require.NoError(t, s.PostCommit(context.Background()))
-
- v2Store := checkpoint.NewV2GitStore(repo, "origin")
- initialCompact1, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID1), 0)
- require.NoError(t, err)
- require.JSONEq(t, "{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-1\"}\n", string(initialCompact1))
- initialContent1, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID1), sessionID)
- require.NoError(t, err)
- initialStart1 := initialContent1.Metadata.CheckpointTranscriptStart
-
- initialCompact2, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID2), 0)
- require.NoError(t, err)
- require.JSONEq(t, "{\"v\":1,\"type\":\"assistant\",\"text\":\"checkpoint-2\"}\n", string(initialCompact2))
- initialContent2, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID2), sessionID)
- require.NoError(t, err)
- initialStart2 := initialContent2.Metadata.CheckpointTranscriptStart
- require.Greater(t, initialStart2, initialStart1, "later checkpoints should start later in transcript.jsonl")
-
- updatedTranscript := `{"type":"human","message":{"content":"build something"}}
-{"type":"assistant","message":{"content":"done building"}}
-{"type":"human","message":{"content":"now finalize it"}}
-{"type":"assistant","message":{"content":"all done"}}
-`
- require.NoError(t, os.WriteFile(state.TranscriptPath, []byte(updatedTranscript), 0o644))
- fakeAgent.fullCompact = []byte("{\"v\":1,\"type\":\"assistant\",\"text\":\"final\"}\n")
-
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- require.Equal(t, []string{cpID1, cpID2}, state.TurnCheckpointIDs)
-
- require.NoError(t, s.HandleTurnEnd(context.Background(), state))
-
- finalCompact1, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID1), 0)
- require.NoError(t, err)
- require.Equal(t, fakeAgent.fullCompact, finalCompact1)
- finalContent1, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID1), sessionID)
- require.NoError(t, err)
- require.Equal(t, initialStart1, finalContent1.Metadata.CheckpointTranscriptStart, "finalization must not rewrite checkpoint start offsets")
-
- finalCompact2, err := v2Store.ReadSessionCompactTranscript(context.Background(), id.MustCheckpointID(cpID2), 0)
- require.NoError(t, err)
- require.Equal(t, fakeAgent.fullCompact, finalCompact2)
- finalContent2, err := v2Store.ReadSessionContentByID(context.Background(), id.MustCheckpointID(cpID2), sessionID)
- require.NoError(t, err)
- require.Equal(t, initialStart2, finalContent2.Metadata.CheckpointTranscriptStart, "finalization must preserve per-checkpoint line references")
-}
-
-// setupSessionWithCheckpoint initializes a session and creates one checkpoint
-// on the shadow branch so there is content available for condensation.
-// Also modifies test.txt to "agent modified content" and includes it in the checkpoint,
-// so content-aware carry-forward comparisons work correctly when commitFilesWithTrailer
-// commits the same content.
-func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Repository, dir, sessionID string) {
- t.Helper()
-
- // Modify test.txt with agent content (same content that commitFilesWithTrailer will commit)
- testFile := filepath.Join(dir, "test.txt")
- require.NoError(t, os.WriteFile(testFile, []byte("agent modified content"), 0o644))
-
- // Create metadata directory with a transcript file
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(testTranscriptPromptResponse), 0o644,
- ))
-
- // SaveStep creates the shadow branch and checkpoint
- // Include test.txt as a modified file so it's saved to the shadow branch
- err := s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{"test.txt"},
- NewFiles: []string{},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err, "SaveStep should succeed to create shadow branch content")
-}
-
-// setupSessionWithCheckpointAndFile initializes a session with a checkpoint for
-// a caller-provided new file. This lets tests create multiple independent
-// sessions that all overlap with the same commit.
-func setupSessionWithCheckpointAndFile(t *testing.T, s *ManualCommitStrategy, dir, sessionID, fileName string) {
- t.Helper()
-
- filePath := filepath.Join(dir, fileName)
- fileContent := "agent content for " + fileName
- require.NoError(t, os.WriteFile(filePath, []byte(fileContent), 0o644))
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(testTranscript), 0o644,
- ))
-
- err := s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{fileName},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint 1",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err, "SaveStep should succeed to create shadow branch content")
-}
-
-// shadowTranscriptSize returns the byte size of the transcript blob on the shadow branch.
-// Used in tests to set CheckpointTranscriptSize without hardcoding sizes.
-func shadowTranscriptSize(t *testing.T, repo *git.Repository, state *SessionState) int64 {
- t.Helper()
- shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID)
- ref, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true)
- require.NoError(t, err)
- commit, err := repo.CommitObject(ref.Hash())
- require.NoError(t, err)
- tree, err := commit.Tree()
- require.NoError(t, err)
- metadataDir := paths.TraceMetadataDir + "/" + state.SessionID
- size, err := tree.Size(metadataDir + "/" + paths.TranscriptFileName)
- require.NoError(t, err)
- return size
-}
-
-// commitWithCheckpointTrailer creates a commit on the current branch with the
-// Trace-Checkpoint trailer in the commit message. This simulates what happens
-// after PrepareCommitMsg adds the trailer and the user completes the commit.
-func commitWithCheckpointTrailer(t *testing.T, repo *git.Repository, dir, checkpointIDStr string) {
- t.Helper()
- commitFilesWithTrailer(t, repo, dir, checkpointIDStr, "test.txt")
-}
-
-// commitFilesWithTrailer stages the given files and commits with a checkpoint trailer.
-// Files must already exist on disk. The test.txt file is modified to ensure there's always something to commit.
-// Important: For tests using content-aware carry-forward, call setupSessionWithCheckpointAndFile first
-// so the shadow branch has the same content that will be committed.
-func commitFilesWithTrailer(t *testing.T, repo *git.Repository, dir, checkpointIDStr string, files ...string) {
- t.Helper()
-
- cpID := id.MustCheckpointID(checkpointIDStr)
-
- // Modify test.txt with agent-like content that matches what setupSessionWithCheckpointAndFile saves
- testFile := filepath.Join(dir, "test.txt")
- content := "agent modified content"
- require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644))
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
-
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
- for _, f := range files {
- _, err = wt.Add(f)
- require.NoError(t, err)
- }
-
- commitMsg := "test commit\n\n" + trailers.CheckpointTrailerKey + ": " + cpID.String() + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@test.com",
- When: time.Now(),
- },
- })
- require.NoError(t, err, "commit with checkpoint trailer should succeed")
-}
-
-// TestPostCommit_OldIdleSession_BaseCommitNotUpdated verifies that when an IDLE
-// session from a previous commit exists, and a NEW session makes a commit, the
-// old IDLE session's BaseCommit is NOT updated to the new HEAD.
-//
-// This is a regression test for the bug where old sessions (IDLE/ENDED) would
-// have their BaseCommit updated, causing them to be incorrectly condensed on
-// future commits because their BaseCommit matched the new shadow branch.
-func TestPostCommit_OldIdleSession_BaseCommitNotUpdated(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // --- Create an old IDLE session from a previous commit ---
- oldSessionID := "old-idle-session"
- setupSessionWithCheckpoint(t, s, repo, dir, oldSessionID)
-
- oldState, err := s.loadSessionState(context.Background(), oldSessionID)
- require.NoError(t, err)
- oldState.Phase = session.PhaseIdle
- oldState.FilesTouched = []string{"old-file.txt"} // Has files touched (important for bug)
- require.NoError(t, s.saveSessionState(context.Background(), oldState))
-
- // Record the old session's BaseCommit BEFORE the new commit
- oldSessionOriginalBaseCommit := oldState.BaseCommit
-
- // Create a commit to move HEAD forward (simulating old session was condensed)
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated"), 0o644))
- _, err = wt.Add("unrelated.txt")
- require.NoError(t, err)
- _, err = wt.Commit("unrelated commit without trailer", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // --- Create a NEW ACTIVE session at the new HEAD ---
- newSessionID := testNewActiveSessionID
- setupSessionWithCheckpoint(t, s, repo, dir, newSessionID)
-
- newState, err := s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
- newState.Phase = session.PhaseActive
- require.NoError(t, s.saveSessionState(context.Background(), newState))
-
- // --- Commit from the new session ---
- commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6")
-
- // Get new HEAD for comparison
- head, err := repo.Head()
- require.NoError(t, err)
- newHead := head.Hash().String()
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // --- Verify: old IDLE session's BaseCommit should NOT be updated ---
- oldState, err = s.loadSessionState(context.Background(), oldSessionID)
- require.NoError(t, err)
- assert.Equal(t, oldSessionOriginalBaseCommit, oldState.BaseCommit,
- "OLD IDLE session's BaseCommit should NOT be updated when a different session commits")
- assert.NotEqual(t, newHead, oldState.BaseCommit,
- "OLD IDLE session's BaseCommit should NOT match new HEAD")
-
- // New ACTIVE session's BaseCommit SHOULD be updated (it was condensed)
- newState, err = s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
- assert.Equal(t, newHead, newState.BaseCommit,
- "NEW ACTIVE session's BaseCommit should be updated after condensation")
-}
-
-// TestPostCommit_OldEndedSession_BaseCommitNotUpdated verifies that when an ENDED
-// session from a previous commit exists (with no new content to condense), and a
-// NEW session makes a commit, the old ENDED session's BaseCommit is NOT updated.
-//
-// This simulates the scenario where:
-// 1. Old session ran and was already condensed (no new transcript content)
-// 2. Old session is now ENDED
-// 3. New session commits
-// 4. Old ENDED session should NOT have BaseCommit updated
-func TestPostCommit_OldEndedSession_BaseCommitNotUpdated(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // --- Create an old ENDED session that has NO new content to condense ---
- oldSessionID := "old-ended-session"
- setupSessionWithCheckpoint(t, s, repo, dir, oldSessionID)
-
- oldState, err := s.loadSessionState(context.Background(), oldSessionID)
- require.NoError(t, err)
- now := time.Now()
- oldState.Phase = session.PhaseEnded
- oldState.EndedAt = &now
- oldState.FilesTouched = []string{"old-file.txt"} // Has files touched
- // Mark transcript as fully condensed (no new content since last checkpoint)
- // The transcript has 2 lines, so CheckpointTranscriptStart=2 means no new content
- oldState.CheckpointTranscriptStart = 2
- require.NoError(t, s.saveSessionState(context.Background(), oldState))
-
- // Record the old session's BaseCommit BEFORE the new commit
- oldSessionOriginalBaseCommit := oldState.BaseCommit
-
- // Create a commit to move HEAD forward
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated"), 0o644))
- _, err = wt.Add("unrelated.txt")
- require.NoError(t, err)
- _, err = wt.Commit("unrelated commit without trailer", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // --- Create a NEW ACTIVE session at the new HEAD ---
- newSessionID := testNewActiveSessionID
- setupSessionWithCheckpoint(t, s, repo, dir, newSessionID)
-
- newState, err := s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
- newState.Phase = session.PhaseActive
- require.NoError(t, s.saveSessionState(context.Background(), newState))
-
- // --- Commit from the new session ---
- commitWithCheckpointTrailer(t, repo, dir, "b1c2d3e4f5a6")
-
- // Get new HEAD for comparison
- head, err := repo.Head()
- require.NoError(t, err)
- newHead := head.Hash().String()
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // --- Verify: old ENDED session's BaseCommit should NOT be updated ---
- oldState, err = s.loadSessionState(context.Background(), oldSessionID)
- require.NoError(t, err)
- assert.Equal(t, oldSessionOriginalBaseCommit, oldState.BaseCommit,
- "OLD ENDED session's BaseCommit should NOT be updated when a different session commits")
- assert.NotEqual(t, newHead, oldState.BaseCommit,
- "OLD ENDED session's BaseCommit should NOT match new HEAD")
-
- // New ACTIVE session's BaseCommit SHOULD be updated
- newState, err = s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
- assert.Equal(t, newHead, newState.BaseCommit,
- "NEW ACTIVE session's BaseCommit should be updated after condensation")
-}
-
-// TestPostCommit_StaleActiveSession_NotCondensed verifies that a stale ACTIVE
-// session (agent killed without Stop hook) is NOT condensed into an unrelated
-// commit from a different session.
-//
-// Root cause: when an agent is killed without the Stop hook firing, its session
-// remains in ACTIVE phase permanently. The overlap check prevents stale sessions
-// with unrelated files from being condensed. The isRecentInteraction guard
-// ensures that genuinely-active sessions (recent LastInteractionTime) skip the
-// overlap check, while stale sessions (old/nil LastInteractionTime) must pass it.
-func TestPostCommit_StaleActiveSession_NotCondensed(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // --- Create a stale ACTIVE session from an old commit ---
- // This simulates an agent that was killed without the Stop hook firing.
- staleSessionID := "stale-active-session"
- setupSessionWithCheckpoint(t, s, repo, dir, staleSessionID)
-
- staleState, err := s.loadSessionState(context.Background(), staleSessionID)
- require.NoError(t, err)
- staleState.Phase = session.PhaseActive
- // The stale session touched "test.txt" (set by setupSessionWithCheckpoint)
- // but the new commit will modify a different file.
- staleState.FilesTouched = []string{"test.txt"}
- // Stale session: LastInteractionTime is old (agent was killed days ago)
- staleTime := time.Now().Add(-48 * time.Hour)
- staleState.LastInteractionTime = &staleTime
- require.NoError(t, s.saveSessionState(context.Background(), staleState))
-
- staleOriginalBaseCommit := staleState.BaseCommit
- staleOriginalStepCount := staleState.StepCount
-
- // Move HEAD forward with an unrelated commit (no trailer)
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated work"), 0o644))
- _, err = wt.Add("unrelated.txt")
- require.NoError(t, err)
- _, err = wt.Commit("unrelated commit", &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // --- Create a NEW ACTIVE session at the new HEAD ---
- newSessionID := testNewActiveSessionID
-
- // Create a new file for the new session (different from stale session's test.txt)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "new-feature.txt"), []byte("new feature content"), 0o644))
-
- metadataDir := ".trace/metadata/" + newSessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"add new feature"}}
-{"type":"assistant","message":{"content":"adding new feature"}}
-`
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(transcript), 0o644,
- ))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: newSessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{"new-feature.txt"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint: new feature",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- newState, err := s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
- newState.Phase = session.PhaseActive
- // New session has recent interaction (agent is genuinely running)
- now := time.Now()
- newState.LastInteractionTime = &now
- require.NoError(t, s.saveSessionState(context.Background(), newState))
-
- // --- Commit ONLY new-feature.txt (not test.txt) with checkpoint trailer ---
- wt, err = repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("new-feature.txt")
- require.NoError(t, err)
-
- cpID := "de1de2de3de4"
- commitMsg := "add new feature\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- head, err := repo.Head()
- require.NoError(t, err)
- newHead := head.Hash().String()
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // --- Verify: stale ACTIVE session was NOT condensed ---
- staleState, err = s.loadSessionState(context.Background(), staleSessionID)
- require.NoError(t, err)
-
- // StepCount should be unchanged (not reset by condensation)
- assert.Equal(t, staleOriginalStepCount, staleState.StepCount,
- "Stale ACTIVE session StepCount should NOT be reset (no condensation)")
-
- // BaseCommit IS updated for ACTIVE sessions (updateBaseCommitIfChanged)
- assert.Equal(t, newHead, staleState.BaseCommit,
- "Stale ACTIVE session BaseCommit should be updated (ACTIVE sessions always get BaseCommit updated)")
- assert.NotEqual(t, staleOriginalBaseCommit, staleState.BaseCommit,
- "Stale ACTIVE session BaseCommit should have changed")
-
- // Phase stays ACTIVE
- assert.Equal(t, session.PhaseActive, staleState.Phase,
- "Stale ACTIVE session should remain ACTIVE")
-
- // --- Verify: new ACTIVE session WAS condensed ---
- newState, err = s.loadSessionState(context.Background(), newSessionID)
- require.NoError(t, err)
-
- // StepCount reset to 0 by condensation
- assert.Equal(t, 0, newState.StepCount,
- "New ACTIVE session StepCount should be reset by condensation")
-
- // BaseCommit updated to new HEAD
- assert.Equal(t, newHead, newState.BaseCommit,
- "New ACTIVE session BaseCommit should be updated after condensation")
-
- // Verify trace/checkpoints/v1 exists (new session was condensed)
- _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err,
- "trace/checkpoints/v1 should exist (new session was condensed)")
-}
-
-// TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed verifies that an IDLE
-// session with hasNew=true but empty FilesTouched is NOT condensed into a commit.
-//
-// This can happen for conversation-only sessions where the transcript grew but no
-// files were modified. Previously, filesOverlapWithContent was called with an empty
-// list and returned false. The shouldCondenseWithOverlapCheck method must also
-// return false when filesTouchedBefore is empty.
-func TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // --- Create an IDLE session with a checkpoint but no files touched ---
- idleSessionID := "idle-no-files-session"
- setupSessionWithCheckpoint(t, s, repo, dir, idleSessionID)
-
- idleState, err := s.loadSessionState(context.Background(), idleSessionID)
- require.NoError(t, err)
- idleState.Phase = session.PhaseIdle
- // Clear FilesTouched to simulate a conversation-only session
- idleState.FilesTouched = nil
- // CheckpointTranscriptStart=0 so sessionHasNewContent returns true
- idleState.CheckpointTranscriptStart = 0
- require.NoError(t, s.saveSessionState(context.Background(), idleState))
-
- idleOriginalStepCount := idleState.StepCount
-
- // --- Make a commit with an unrelated file ---
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "other-work.txt"), []byte("other work"), 0o644))
- _, err = wt.Add("other-work.txt")
- require.NoError(t, err)
-
- cpID := "f1f2f3f4f5f6"
- commitMsg := "other work\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // --- Verify: IDLE session with no files was NOT condensed ---
- idleState, err = s.loadSessionState(context.Background(), idleSessionID)
- require.NoError(t, err)
-
- assert.Equal(t, idleOriginalStepCount, idleState.StepCount,
- "IDLE session with empty FilesTouched should NOT be condensed")
- assert.Equal(t, session.PhaseIdle, idleState.Phase,
- "IDLE session should remain IDLE")
- // BaseCommit is NOT updated for non-ACTIVE sessions (updateBaseCommitIfChanged skips them)
-}
-
-// TestPostCommit_IdleSession_NoTranscriptFallbackForCarryForward verifies that
-// carry-forward computation for IDLE sessions does NOT fall back to transcript
-// extraction. Only ACTIVE sessions (mid-session commits before Stop) should parse
-// the transcript, because IDLE sessions have FilesTouched populated by SaveStep.
-//
-// Regression test: resolveFilesTouched unconditionally falls back to transcript
-// extraction, but the PostCommit call site must gate it on IsActive().
-func TestPostCommit_IdleSession_NoTranscriptFallbackForCarryForward(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // Create an IDLE session with checkpoint
- sessionID := "idle-transcript-guard"
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseIdle
- // Clear FilesTouched to simulate the edge case
- state.FilesTouched = nil
- // Set transcript info so transcript extraction WOULD find files if called
- state.AgentType = agent.AgentTypeGemini
- transcriptPath := filepath.Join(dir, "idle-transcript.json")
- transcript := `{
- "messages": [
- {"type": "user", "content": [{"text": "create file"}]},
- {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "` + filepath.Join(dir, "test.txt") + `"}}]}
- ]
-}`
- require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
- state.TranscriptPath = transcriptPath
- state.CheckpointTranscriptStart = 0
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- originalStepCount := state.StepCount
-
- // Commit the file the transcript references — if transcript extraction
- // ran, it would find overlap and trigger condensation
- wt, err := repo.Worktree()
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("committed"), 0o644))
- _, err = wt.Add("test.txt")
- require.NoError(t, err)
-
- cpID := "a1a2a3a4a5a6"
- commitMsg := "commit test.txt\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify: IDLE session was NOT condensed (transcript fallback was skipped)
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.Equal(t, originalStepCount, state.StepCount,
- "IDLE session should NOT be condensed via transcript fallback — only ACTIVE sessions get transcript extraction for carry-forward")
-}
-
-// TestPostCommit_IdleSession_SkipsSentinelWait is a regression test verifying that
-// PostCommit for an IDLE session with AgentType=ClaudeCode and a TranscriptPath
-// completes quickly without hitting the 3s sentinel timeout in PrepareTranscript.
-//
-// Before the fix, the transcript extraction functions called PrepareTranscript unconditionally,
-// which triggered waitForTranscriptFlush (3s timeout) even for idle/ended sessions
-// where the transcript was already fully flushed.
-//
-// After the fix, PrepareTranscript is only called when state.Phase.IsActive().
-func TestPostCommit_IdleSession_SkipsSentinelWait(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-idle-skip-sentinel"
-
- // Initialize session and save a checkpoint
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to IDLE, set AgentType to Claude Code, and set TranscriptPath
- // Without TranscriptPath, the PrepareTranscript code path is never reached.
- // Without AgentType=ClaudeCode, the sentinel wait is not triggered.
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseIdle
- state.LastInteractionTime = nil
- state.FilesTouched = []string{"test.txt"}
- state.AgentType = agent.AgentTypeClaudeCode
-
- // Create a transcript file so PrepareTranscript would be triggered if not guarded
- transcriptFile := filepath.Join(dir, ".trace", "transcript-"+sessionID+".jsonl")
- require.NoError(t, os.MkdirAll(filepath.Dir(transcriptFile), 0o755))
- require.NoError(t, os.WriteFile(transcriptFile, []byte(`{"type":"human"}`+"\n"), 0o644))
- state.TranscriptPath = transcriptFile
-
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Create a commit WITH the Trace-Checkpoint trailer
- commitWithCheckpointTrailer(t, repo, dir, "a1a2a3a4a5a6")
-
- // Time PostCommit — before the fix this would take ~3s+ due to sentinel timeout
- start := time.Now()
- err = s.PostCommit(context.Background())
- elapsed := time.Since(start)
- require.NoError(t, err)
-
- // Assert it completes well under the 3s sentinel timeout.
- // Normal PostCommit for these tests runs in <500ms (git operations only).
- assert.Less(t, elapsed, 2*time.Second,
- "IDLE session PostCommit should skip sentinel wait and complete in <2s, took %v", elapsed)
-
- // Verify condensation still happened correctly
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err, "trace/checkpoints/v1 branch should exist after condensation")
- assert.NotNil(t, sessionsRef)
-}
-
-// TestPostCommit_EndedSession_SkipsSentinelWait is the same regression test as
-// TestPostCommit_IdleSession_SkipsSentinelWait but for ENDED phase sessions.
-// Both IDLE and ENDED sessions should skip the sentinel wait since their
-// transcripts are already fully flushed.
-func TestPostCommit_EndedSession_SkipsSentinelWait(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-ended-skip-sentinel"
-
- // Initialize session and save a checkpoint
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to ENDED, set AgentType to Claude Code, and set TranscriptPath
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- now := time.Now()
- state.Phase = session.PhaseEnded
- state.EndedAt = &now
- state.FilesTouched = []string{"test.txt"}
- state.AgentType = agent.AgentTypeClaudeCode
-
- // Create a transcript file so PrepareTranscript would be triggered if not guarded
- transcriptFile := filepath.Join(dir, ".trace", "transcript-"+sessionID+".jsonl")
- require.NoError(t, os.MkdirAll(filepath.Dir(transcriptFile), 0o755))
- require.NoError(t, os.WriteFile(transcriptFile, []byte(`{"type":"human"}`+"\n"), 0o644))
- state.TranscriptPath = transcriptFile
-
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Create a commit WITH the Trace-Checkpoint trailer
- commitWithCheckpointTrailer(t, repo, dir, "e1e2e3e4e5e6")
-
- // Time PostCommit — before the fix this would take ~3s+ due to sentinel timeout
- start := time.Now()
- err = s.PostCommit(context.Background())
- elapsed := time.Since(start)
- require.NoError(t, err)
-
- // Assert it completes well under the 3s sentinel timeout
- assert.Less(t, elapsed, 2*time.Second,
- "ENDED session PostCommit should skip sentinel wait and complete in <2s, took %v", elapsed)
-
- // Verify condensation still happened correctly
- sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err, "trace/checkpoints/v1 branch should exist after condensation")
- assert.NotNil(t, sessionsRef)
-}
-
-// TestPostCommit_EndedSession_SetsFullyCondensed verifies that an ENDED session
-// is marked FullyCondensed after condensation when no carry-forward files remain.
-func TestPostCommit_EndedSession_SetsFullyCondensed(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-postcommit-ended-fully-condensed"
-
- // Initialize session and save a checkpoint
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to ENDED with files touched (the committed file matches shadow branch)
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- now := time.Now()
- state.Phase = session.PhaseEnded
- state.EndedAt = &now
- state.FilesTouched = []string{"test.txt"}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Create a commit that includes test.txt — this commits the only touched file,
- // so carry-forward will be empty afterward.
- commitWithCheckpointTrailer(t, repo, dir, "fc01fc01fc01")
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify FullyCondensed is set
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.True(t, state.FullyCondensed,
- "ENDED session with no carry-forward should be marked FullyCondensed")
- assert.Equal(t, session.PhaseEnded, state.Phase)
- assert.Empty(t, state.FilesTouched,
- "FilesTouched should be empty after all files were committed")
-}
-
-// TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit verifies that
-// a FullyCondensed ENDED session is skipped entirely on subsequent commits,
-// avoiding redundant shadow branch resolution and condensation attempts.
-func TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-postcommit-skip-fully-condensed"
-
- // Initialize session and save a checkpoint
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- // Set phase to ENDED with files touched
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- now := time.Now()
- state.Phase = session.PhaseEnded
- state.EndedAt = &now
- state.FilesTouched = []string{"test.txt"}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // First commit — condenses the ENDED session and marks it FullyCondensed
- commitWithCheckpointTrailer(t, repo, dir, "fc02fc02fc02")
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify it's now fully condensed
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- require.True(t, state.FullyCondensed)
-
- // Record the LastCheckpointID — this should persist (the reason the session exists)
- lastCPID := state.LastCheckpointID
-
- // Second commit — the fully-condensed session should be skipped entirely.
- // Create a new file so there's something to commit.
- require.NoError(t, os.WriteFile(filepath.Join(dir, "other.txt"), []byte("other"), 0o644))
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("other.txt")
- require.NoError(t, err)
- commitMsg := "second commit\n\n" + trailers.CheckpointTrailerKey + ": fc03fc03fc03\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test",
- Email: "test@test.com",
- When: time.Now(),
- },
- })
- require.NoError(t, err)
-
- // Run PostCommit again
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify state is unchanged — the session was skipped, not re-processed
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.True(t, state.FullyCondensed,
- "FullyCondensed should still be true after being skipped")
- assert.Equal(t, session.PhaseEnded, state.Phase)
- assert.Equal(t, lastCPID, state.LastCheckpointID,
- "LastCheckpointID should be preserved across skipped commits")
-}
-
-// TestPostCommit_NonEndedSession_NotMarkedFullyCondensed verifies that ACTIVE
-// and IDLE sessions are never marked FullyCondensed, even when condensed with
-// no carry-forward. Only ENDED sessions get the flag.
-func TestPostCommit_NonEndedSession_NotMarkedFullyCondensed(t *testing.T) {
- for _, phase := range []session.Phase{session.PhaseActive, session.PhaseIdle} {
- t.Run(string(phase), func(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-postcommit-" + string(phase) + "-not-fully-condensed"
-
- // Initialize session and save a checkpoint
- setupSessionWithCheckpoint(t, s, repo, dir, sessionID)
-
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = phase
- state.FilesTouched = []string{"test.txt"}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // Commit the file
- commitWithCheckpointTrailer(t, repo, dir, "fc04fc04fc04")
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify FullyCondensed is NOT set
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- assert.False(t, state.FullyCondensed,
- "%s sessions must never be marked FullyCondensed", phase)
- })
- }
-}
-
-// TestPostCommit_ActiveSession_DifferentFilesThanCommit_ShouldCondense verifies
-// that when an ACTIVE session's Turn 1 touched file A (e.g., a cache file) but
-// Turn 2 commits different files B and C, condensation still happens.
-//
-// This is a regression test for the bug where shouldCondenseWithOverlapCheck
-// incorrectly skipped condensation because filesTouchedBefore (from Turn 1)
-// didn't overlap with the committed files (from Turn 2). ACTIVE sessions with a
-// recent LastInteractionTime should condense when hasNew is true — the overlap
-// check is only meaningful for IDLE/ENDED sessions and stale ACTIVE sessions.
-func TestPostCommit_ActiveSession_DifferentFilesThanCommit_ShouldCondense(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- sessionID := "test-active-different-files"
-
- // --- Turn 1: Save checkpoint touching a cache file (not what will be committed) ---
- // Write the cache file so SaveStep can snapshot it
- cacheFile := filepath.Join(dir, ".gitstats_cache.sqlite3")
- require.NoError(t, os.WriteFile(cacheFile, []byte("cache data"), 0o644))
-
- metadataDir := ".trace/metadata/" + sessionID
- metadataDirAbs := filepath.Join(dir, metadataDir)
- require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755))
-
- transcript := `{"type":"human","message":{"content":"analyze git stats"}}
-{"type":"assistant","message":{"content":"analyzing stats, creating cache"}}
-`
- require.NoError(t, os.WriteFile(
- filepath.Join(metadataDirAbs, paths.TranscriptFileName),
- []byte(transcript), 0o644,
- ))
-
- err = s.SaveStep(context.Background(), StepContext{
- SessionID: sessionID,
- ModifiedFiles: []string{},
- NewFiles: []string{".gitstats_cache.sqlite3"},
- DeletedFiles: []string{},
- MetadataDir: metadataDir,
- MetadataDirAbs: metadataDirAbs,
- CommitMessage: "Checkpoint: cache created",
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-
- // Set phase to ACTIVE (agent mid-turn) with recent interaction
- state, err := s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
- state.Phase = session.PhaseActive
- // FilesTouched reflects Turn 1's cache file — NOT the files about to be committed
- state.FilesTouched = []string{".gitstats_cache.sqlite3"}
- now := time.Now()
- state.LastInteractionTime = &now
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- // --- Turn 2: Agent commits DIFFERENT files (README.md, org_commit_activity.py) ---
- require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Git Stats"), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "org_commit_activity.py"), []byte("print('hello')"), 0o644))
-
- wt, err := repo.Worktree()
- require.NoError(t, err)
- _, err = wt.Add("README.md")
- require.NoError(t, err)
- _, err = wt.Add("org_commit_activity.py")
- require.NoError(t, err)
-
- cpID := "d1d2d3d4d5d6"
- commitMsg := "Add git stats tools\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n"
- _, err = wt.Commit(commitMsg, &git.CommitOptions{
- Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
- })
- require.NoError(t, err)
-
- // --- Run PostCommit ---
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // --- Verify condensation happened ---
- state, err = s.loadSessionState(context.Background(), sessionID)
- require.NoError(t, err)
-
- // StepCount should be 1 because carry-forward created a new checkpoint for
- // .gitstats_cache.sqlite3 which was NOT committed (remaining agent work)
- assert.Equal(t, 1, state.StepCount,
- "ACTIVE session StepCount should be 1 (carry-forward for uncommitted cache file)")
-
- // Phase stays ACTIVE
- assert.Equal(t, session.PhaseActive, state.Phase,
- "ACTIVE session should stay ACTIVE after condensation")
-
- // trace/checkpoints/v1 branch should exist
- _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
- require.NoError(t, err,
- "trace/checkpoints/v1 should exist — ACTIVE session with different files must still condense")
-}
-
-// TestPostCommit_EmptyEndedSession_MarkedFullyCondensed verifies that an ENDED
-// session with no FilesTouched and no new content (hasNew=false) is marked
-// FullyCondensed on the next PostCommit. Without this, empty ENDED sessions
-// go through HandleDiscardIfNoFiles (which is a no-op for ENDED) and are
-// iterated on every future PostCommit forever.
-func TestPostCommit_EmptyEndedSession_MarkedFullyCondensed(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
-
- // We need a real session with BaseCommit/WorktreeID to pass PostCommit's
- // session iteration. Use setupSessionWithCheckpoint to create the plumbing,
- // then create a separate empty ENDED session sharing the same base commit.
- helperSessionID := "helper-session"
- setupSessionWithCheckpoint(t, s, repo, dir, helperSessionID)
-
- helperState, err := s.loadSessionState(context.Background(), helperSessionID)
- require.NoError(t, err)
-
- // Create the empty ENDED session — no files, no steps, no shadow branch content
- emptySessionID := "empty-ended-session"
- endedAt := time.Now().Add(-2 * time.Hour)
- emptyState := &SessionState{
- SessionID: emptySessionID,
- BaseCommit: helperState.BaseCommit,
- WorktreePath: helperState.WorktreePath,
- WorktreeID: helperState.WorktreeID,
- StartedAt: time.Now().Add(-3 * time.Hour),
- Phase: session.PhaseEnded,
- EndedAt: &endedAt,
- FilesTouched: nil,
- StepCount: 0,
- }
- require.NoError(t, s.saveSessionState(context.Background(), emptyState))
-
- // Create a commit with checkpoint trailer
- commitWithCheckpointTrailer(t, repo, dir, "e1e2e3e4e5e6")
-
- // Run PostCommit
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- // Verify: empty ENDED session should be marked FullyCondensed
- state, err := s.loadSessionState(context.Background(), emptySessionID)
- require.NoError(t, err)
- require.NotNil(t, state)
- assert.True(t, state.FullyCondensed,
- "ENDED session with no files and no new content should be marked FullyCondensed")
- assert.Equal(t, session.PhaseEnded, state.Phase,
- "Phase should stay ENDED")
-}
-
-// TestCountWarnableStaleEndedSessions verifies that the warning only counts the
-// same ENDED sessions that 'trace doctor' can actually condense.
-// Uses t.Chdir — do NOT add t.Parallel().
-func TestCountWarnableStaleEndedSessions(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- setupSessionWithCheckpoint(t, s, repo, dir, "warnable-session")
-
- warnableState, err := s.loadSessionState(context.Background(), "warnable-session")
- require.NoError(t, err)
- warnableState.Phase = session.PhaseEnded
- warnableState.FullyCondensed = false
- require.NoError(t, s.saveSessionState(context.Background(), warnableState))
-
- sessions := []*SessionState{
- warnableState,
- {
- SessionID: "no-shadow-branch",
- BaseCommit: "1234567890abcdef1234567890abcdef12345678",
- WorktreeID: warnableState.WorktreeID,
- Phase: session.PhaseEnded,
- FullyCondensed: false,
- StepCount: 3,
- },
- {
- SessionID: "zero-steps",
- BaseCommit: warnableState.BaseCommit,
- WorktreeID: warnableState.WorktreeID,
- Phase: session.PhaseEnded,
- FullyCondensed: false,
- StepCount: 0,
- },
- {
- SessionID: "fully-condensed",
- BaseCommit: warnableState.BaseCommit,
- WorktreeID: warnableState.WorktreeID,
- Phase: session.PhaseEnded,
- FullyCondensed: true,
- StepCount: 3,
- },
- {
- SessionID: "idle-session",
- BaseCommit: warnableState.BaseCommit,
- WorktreeID: warnableState.WorktreeID,
- Phase: session.PhaseIdle,
- FullyCondensed: false,
- StepCount: 3,
- },
- }
-
- assert.Equal(t, 1, countWarnableStaleEndedSessions(repo, sessions))
-}
-
-// TestPostCommit_WarnStaleEndedSessions_AfterProcessing verifies that the
-// warning is emitted only for sessions that remain stale AFTER the current
-// commit is processed.
-// Uses t.Chdir — do NOT add t.Parallel().
-func TestPostCommit_WarnStaleEndedSessions_AfterProcessing(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
-
- repo, err := git.PlainOpen(dir)
- require.NoError(t, err)
-
- s := &ManualCommitStrategy{}
- type sessionFile struct {
- sessionID string
- fileName string
- }
- sessionFiles := []sessionFile{
- {"ended-a", "stale-a.txt"},
- {"ended-b", "stale-b.txt"},
- {"ended-c", "stale-c.txt"},
- }
-
- filesToCommit := make([]string, 0, len(sessionFiles))
- for _, sf := range sessionFiles {
- setupSessionWithCheckpointAndFile(t, s, dir, sf.sessionID, sf.fileName)
-
- state, loadErr := s.loadSessionState(context.Background(), sf.sessionID)
- require.NoError(t, loadErr)
- now := time.Now()
- state.Phase = session.PhaseEnded
- state.EndedAt = &now
- state.FilesTouched = []string{sf.fileName}
- require.NoError(t, s.saveSessionState(context.Background(), state))
-
- filesToCommit = append(filesToCommit, sf.fileName)
- }
-
- commitFilesWithTrailer(t, repo, dir, "abc123def456", filesToCommit...)
-
- // Capture warning output via the injectable stderrWriter instead of
- // mutating the process-global os.Stderr.
- var buf bytes.Buffer
- oldWriter := stderrWriter
- stderrWriter = &buf
- defer func() { stderrWriter = oldWriter }()
-
- err = s.PostCommit(context.Background())
- require.NoError(t, err)
-
- assert.NotContains(t, buf.String(), "trace doctor",
- "warning should be suppressed when this commit already condensed the stale ended sessions")
-}
-
-// TestWarnStaleEndedSessions_RateLimit verifies the 24h sentinel file gate.
-// Uses t.Chdir — do NOT add t.Parallel().
-func TestWarnStaleEndedSessions_RateLimit(t *testing.T) {
- dir := setupGitRepo(t)
- t.Chdir(dir)
- ctx := context.Background()
-
- // First call: no sentinel file → should write to stderr
- var buf bytes.Buffer
- warnStaleEndedSessionsTo(ctx, 5, &buf)
- assert.Contains(t, buf.String(), "trace doctor")
-
- // Sentinel file now exists with current mtime → second call suppressed
- buf.Reset()
- warnStaleEndedSessionsTo(ctx, 5, &buf)
- assert.Empty(t, buf.String(), "second call within window must be suppressed")
-
- // Backdate sentinel file by 25h → call should warn again
- commonDir, err := GetGitCommonDir(ctx)
- require.NoError(t, err)
- warnFile := filepath.Join(commonDir, session.SessionStateDirName, staleEndedSessionWarnFile)
- past := time.Now().Add(-25 * time.Hour)
- require.NoError(t, os.Chtimes(warnFile, past, past))
-
- buf.Reset()
- warnStaleEndedSessionsTo(ctx, 5, &buf)
- assert.Contains(t, buf.String(), "trace doctor")
-}
diff --git a/cli/strategy/push_common_2_test.go b/cli/strategy/push_common_2_test.go
new file mode 100644
index 0000000..4d1c50f
--- /dev/null
+++ b/cli/strategy/push_common_2_test.go
@@ -0,0 +1,813 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/checkpoint"
+ "github.com/GrayCodeAI/trace/cli/checkpoint/id"
+ "github.com/GrayCodeAI/trace/cli/paths"
+ "github.com/GrayCodeAI/trace/cli/testutil"
+ "github.com/GrayCodeAI/trace/redact"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+ "github.com/go-git/go-git/v6/plumbing/filemode"
+ "github.com/go-git/go-git/v6/plumbing/object"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef verifies that URL
+// targets reconcile against the temporary fetched ref instead of any origin
+// tracking state.
+//
+// Not parallel: uses t.Chdir() (required for OpenRepository).
+func TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef(t *testing.T) {
+ ctx := context.Background()
+ branchName := paths.MetadataBranchName
+
+ bareDir := t.TempDir()
+ setupDir := t.TempDir()
+ gitRun := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = dir
+ cmd.Env = testutil.GitIsolatedEnv()
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, "git %v in %s failed: %s", args, dir, out)
+ }
+
+ gitRun(bareDir, "init", "--bare", "-b", "main")
+ gitRun(setupDir, "clone", bareDir, ".")
+ gitRun(setupDir, "config", "user.email", "test@test.com")
+ gitRun(setupDir, "config", "user.name", "Test User")
+ gitRun(setupDir, "config", "commit.gpgsign", "false")
+ require.NoError(t, os.WriteFile(filepath.Join(setupDir, "README.md"), []byte("# Test"), 0o644))
+ gitRun(setupDir, "add", ".")
+ gitRun(setupDir, "commit", "-m", "init")
+ gitRun(setupDir, "push", "origin", "main")
+
+ gitRun(setupDir, "checkout", "--orphan", branchName)
+ gitRun(setupDir, "rm", "-rf", ".")
+ baseDir := filepath.Join(setupDir, "aa", "aaaaaaaaaa")
+ require.NoError(t, os.MkdirAll(baseDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(baseDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"aaaaaaaaaaaa"}`), 0o644))
+ gitRun(setupDir, "add", ".")
+ gitRun(setupDir, "commit", "-m", "Checkpoint: aaaaaaaaaaaa")
+ gitRun(setupDir, "push", "origin", branchName)
+ gitRun(setupDir, "checkout", "main")
+
+ cloneDir := filepath.Join(t.TempDir(), "clone")
+ require.NoError(t, os.MkdirAll(cloneDir, 0o755))
+ gitRun(cloneDir, "clone", bareDir, ".")
+ gitRun(cloneDir, "config", "user.email", "test@test.com")
+ gitRun(cloneDir, "config", "user.name", "Test User")
+ gitRun(cloneDir, "config", "commit.gpgsign", "false")
+ gitRun(cloneDir, "branch", branchName, "origin/"+branchName)
+
+ gitRun(cloneDir, "checkout", "--orphan", "temp-orphan")
+ gitRun(cloneDir, "rm", "-rf", ".")
+ localDir := filepath.Join(cloneDir, "cc", "cccccccccc")
+ require.NoError(t, os.MkdirAll(localDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"cccccccccccc"}`), 0o644))
+ gitRun(cloneDir, "add", ".")
+ gitRun(cloneDir, "commit", "-m", "Checkpoint: cccccccccccc")
+ gitRun(cloneDir, "branch", "-f", branchName, "temp-orphan")
+ gitRun(cloneDir, "checkout", "main")
+
+ repo, err := git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+ localRefBeforeFetch, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ require.NoError(t, err)
+ staleOriginRef := plumbing.NewHashReference(
+ plumbing.NewRemoteReferenceName("origin", branchName),
+ localRefBeforeFetch.Hash(),
+ )
+ require.NoError(t, repo.Storer.SetReference(staleOriginRef))
+
+ t.Chdir(cloneDir)
+
+ err = fetchAndRebaseSessionsCommon(ctx, "file://"+bareDir, branchName)
+ require.NoError(t, err)
+
+ repo, err = git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+
+ localRef, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ require.NoError(t, err)
+
+ tipCommit, err := repo.CommitObject(localRef.Hash())
+ require.NoError(t, err)
+ require.Len(t, tipCommit.ParentHashes, 1)
+
+ tree, err := tipCommit.Tree()
+ require.NoError(t, err)
+
+ entries := make(map[string]object.TreeEntry)
+ require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
+ assert.Contains(t, entries, "aa/aaaaaaaaaa/metadata.json", "remote checkpoint should be preserved")
+ assert.Contains(t, entries, "cc/cccccccccc/metadata.json", "local checkpoint should be preserved")
+
+ _, err = repo.Reference(plumbing.ReferenceName("refs/trace-fetch-tmp/"+branchName), true)
+ assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up")
+}
+
+// TestFetchAndRebase_FlaggedOriginTarget_UsesTempRef verifies that enabling
+// filtered_fetches for a normal remote-name target follows the temp-ref
+// path and still cleans up after rebasing.
+//
+// Not parallel: uses t.Chdir() (required for OpenRepository).
+func TestFetchAndRebase_FlaggedOriginTarget_UsesTempRef(t *testing.T) {
+ ctx := context.Background()
+ branchName := paths.MetadataBranchName
+
+ bareDir := t.TempDir()
+ setupDir := t.TempDir()
+ gitRun := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = dir
+ cmd.Env = testutil.GitIsolatedEnv()
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, "git %v in %s failed: %s", args, dir, out)
+ }
+
+ gitRun(bareDir, "init", "--bare", "-b", "main")
+ gitRun(setupDir, "clone", bareDir, ".")
+ gitRun(setupDir, "config", "user.email", "test@test.com")
+ gitRun(setupDir, "config", "user.name", "Test User")
+ gitRun(setupDir, "config", "commit.gpgsign", "false")
+ require.NoError(t, os.WriteFile(filepath.Join(setupDir, "README.md"), []byte("# Test"), 0o644))
+ gitRun(setupDir, "add", ".")
+ gitRun(setupDir, "commit", "-m", "init")
+ gitRun(setupDir, "push", "origin", "main")
+
+ gitRun(setupDir, "checkout", "--orphan", branchName)
+ gitRun(setupDir, "rm", "-rf", ".")
+ baseDir := filepath.Join(setupDir, "aa", "aaaaaaaaaa")
+ require.NoError(t, os.MkdirAll(baseDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(baseDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"aaaaaaaaaaaa"}`), 0o644))
+ gitRun(setupDir, "add", ".")
+ gitRun(setupDir, "commit", "-m", "Checkpoint: aaaaaaaaaaaa")
+ gitRun(setupDir, "push", "origin", branchName)
+ gitRun(setupDir, "checkout", "main")
+
+ cloneDir := filepath.Join(t.TempDir(), "clone")
+ require.NoError(t, os.MkdirAll(cloneDir, 0o755))
+ gitRun(cloneDir, "clone", bareDir, ".")
+ gitRun(cloneDir, "config", "user.email", "test@test.com")
+ gitRun(cloneDir, "config", "user.name", "Test User")
+ gitRun(cloneDir, "config", "commit.gpgsign", "false")
+ gitRun(cloneDir, "branch", branchName, "origin/"+branchName)
+ require.NoError(t, os.MkdirAll(filepath.Join(cloneDir, ".trace"), 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(cloneDir, ".trace", "settings.json"),
+ []byte(`{"enabled": true, "strategy_options": {"filtered_fetches": true}}`),
+ 0o644,
+ ))
+
+ gitRun(cloneDir, "checkout", "--orphan", "temp-orphan")
+ gitRun(cloneDir, "rm", "-rf", ".")
+ localDir := filepath.Join(cloneDir, "cc", "cccccccccc")
+ require.NoError(t, os.MkdirAll(localDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"),
+ []byte(`{"checkpoint_id":"cccccccccccc"}`), 0o644))
+ gitRun(cloneDir, "add", ".")
+ gitRun(cloneDir, "commit", "-m", "Checkpoint: cccccccccccc")
+ gitRun(cloneDir, "branch", "-f", branchName, "temp-orphan")
+ gitRun(cloneDir, "checkout", "main")
+
+ repo, err := git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+ localRefBeforeFetch, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ require.NoError(t, err)
+ staleOriginRef := plumbing.NewHashReference(
+ plumbing.NewRemoteReferenceName("origin", branchName),
+ localRefBeforeFetch.Hash(),
+ )
+ require.NoError(t, repo.Storer.SetReference(staleOriginRef))
+
+ t.Chdir(cloneDir)
+
+ err = fetchAndRebaseSessionsCommon(ctx, "origin", branchName)
+ require.NoError(t, err)
+
+ repo, err = git.PlainOpen(cloneDir)
+ require.NoError(t, err)
+
+ localRef, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ require.NoError(t, err)
+
+ tipCommit, err := repo.CommitObject(localRef.Hash())
+ require.NoError(t, err)
+ require.Len(t, tipCommit.ParentHashes, 1)
+
+ tree, err := tipCommit.Tree()
+ require.NoError(t, err)
+
+ entries := make(map[string]object.TreeEntry)
+ require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
+ assert.Contains(t, entries, "aa/aaaaaaaaaa/metadata.json", "remote checkpoint should be preserved")
+ assert.Contains(t, entries, "cc/cccccccccc/metadata.json", "local checkpoint should be preserved")
+
+ _, err = repo.Reference(plumbing.ReferenceName("refs/trace-fetch-tmp/"+branchName), true)
+ assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up")
+}
+
+// TestIsCheckpointRemoteCommitted verifies that the discoverability check reads
+// the committed content of .trace/settings.json at HEAD, not just tracking status.
+// Not parallel: uses t.Chdir().
+func TestIsCheckpointRemoteCommitted(t *testing.T) {
+ checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}`
+
+ t.Run("false when settings.json not committed", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Create .trace/settings.json with checkpoint_remote but don't commit it
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+
+ t.Chdir(tmpDir)
+ assert.False(t, isCheckpointRemoteCommitted(context.Background()))
+ })
+
+ t.Run("false when committed settings.json has no checkpoint_remote", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Commit settings.json without checkpoint_remote
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings")
+
+ t.Chdir(tmpDir)
+ assert.False(t, isCheckpointRemoteCommitted(context.Background()))
+ })
+
+ t.Run("true when committed settings.json has checkpoint_remote", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Commit settings.json with checkpoint_remote
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings")
+
+ t.Chdir(tmpDir)
+ assert.True(t, isCheckpointRemoteCommitted(context.Background()))
+ })
+
+ t.Run("false when checkpoint_remote only in local changes", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Commit settings.json without checkpoint_remote
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings without remote")
+
+ // Now add checkpoint_remote locally but don't commit
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+
+ t.Chdir(tmpDir)
+ assert.False(t, isCheckpointRemoteCommitted(context.Background()),
+ "uncommitted checkpoint_remote should not count as discoverable")
+ })
+
+ t.Run("works from subdirectory", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings")
+
+ subDir := filepath.Join(tmpDir, "subdir")
+ require.NoError(t, os.MkdirAll(subDir, 0o755))
+ t.Chdir(subDir)
+ assert.True(t, isCheckpointRemoteCommitted(context.Background()),
+ "should detect committed checkpoint_remote from subdirectory")
+ })
+}
+
+// TestPrintSettingsCommitHint verifies the hint only prints for URL targets
+// when checkpoint_remote is not discoverable from committed settings, and only
+// once per process via sync.Once.
+// Not parallel: uses t.Chdir() and resets package-level settingsHintOnce.
+func TestPrintSettingsCommitHint(t *testing.T) {
+ checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}`
+
+ t.Run("no hint for non-URL target", func(t *testing.T) {
+ settingsHintOnce = sync.Once{}
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+ t.Chdir(tmpDir)
+
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ printSettingsCommitHint(context.Background(), "origin")
+
+ w.Close()
+ var buf bytes.Buffer
+ if _, readErr := buf.ReadFrom(r); readErr != nil {
+ t.Fatalf("read pipe: %v", readErr)
+ }
+ os.Stderr = old
+
+ assert.Empty(t, buf.String(), "should not print hint for non-URL target")
+ })
+
+ t.Run("hint when checkpoint_remote not in committed settings", func(t *testing.T) {
+ settingsHintOnce = sync.Once{}
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Create .trace/settings.json but don't commit it
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+ t.Chdir(tmpDir)
+
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
+
+ w.Close()
+ var buf bytes.Buffer
+ if _, readErr := buf.ReadFrom(r); readErr != nil {
+ t.Fatalf("read pipe: %v", readErr)
+ }
+ os.Stderr = old
+
+ assert.Contains(t, buf.String(), "does not contain checkpoint_remote")
+ assert.Contains(t, buf.String(), "trace.io will not be able to discover")
+ })
+
+ t.Run("hint when committed settings lacks checkpoint_remote", func(t *testing.T) {
+ settingsHintOnce = sync.Once{}
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Commit settings.json without checkpoint_remote
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings")
+ t.Chdir(tmpDir)
+
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
+
+ w.Close()
+ var buf bytes.Buffer
+ if _, readErr := buf.ReadFrom(r); readErr != nil {
+ t.Fatalf("read pipe: %v", readErr)
+ }
+ os.Stderr = old
+
+ assert.Contains(t, buf.String(), "does not contain checkpoint_remote",
+ "should warn when committed settings.json exists but lacks checkpoint_remote")
+ })
+
+ t.Run("no hint when checkpoint_remote is committed", func(t *testing.T) {
+ settingsHintOnce = sync.Once{}
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ // Commit settings.json with checkpoint_remote
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(checkpointRemoteSettings), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "add settings with checkpoint remote")
+ t.Chdir(tmpDir)
+
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
+
+ w.Close()
+ var buf bytes.Buffer
+ if _, readErr := buf.ReadFrom(r); readErr != nil {
+ t.Fatalf("read pipe: %v", readErr)
+ }
+ os.Stderr = old
+
+ assert.Empty(t, buf.String(), "should not print hint when checkpoint_remote is committed")
+ })
+
+ t.Run("prints only once per process", func(t *testing.T) {
+ settingsHintOnce = sync.Once{}
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+ t.Chdir(tmpDir)
+
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ // Call twice — should only print once
+ printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
+ printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
+
+ w.Close()
+ var buf bytes.Buffer
+ if _, readErr := buf.ReadFrom(r); readErr != nil {
+ t.Fatalf("read pipe: %v", readErr)
+ }
+ os.Stderr = old
+
+ count := bytes.Count(buf.Bytes(), []byte("does not contain checkpoint_remote"))
+ assert.Equal(t, 1, count, "hint should print exactly once, got %d", count)
+ })
+}
+
+func TestIsCheckpointsVersion2Committed(t *testing.T) {
+ t.Run("false when settings.json not committed", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
+
+ t.Chdir(tmpDir)
+ assert.False(t, isCheckpointsVersion2Committed(context.Background()))
+ })
+
+ t.Run("true when checkpoints_version 2 is committed", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "enable checkpoints_version 2")
+
+ t.Chdir(tmpDir)
+ assert.True(t, isCheckpointsVersion2Committed(context.Background()))
+ })
+}
+
+// setupCheckpointsV2CommittedRepo creates a temp repo with checkpoints_version: 2
+// set in the committed .trace/settings.json and chdirs into it. Returns an opened
+// *git.Repository for populating checkpoints.
+func setupCheckpointsV2CommittedRepo(t *testing.T) *git.Repository {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+ testutil.InitRepo(t, tmpDir)
+ testutil.WriteFile(t, tmpDir, "f.txt", "init")
+ testutil.GitAdd(t, tmpDir, "f.txt")
+ testutil.GitCommit(t, tmpDir, "init")
+
+ traceDir := filepath.Join(tmpDir, ".trace")
+ require.NoError(t, os.MkdirAll(traceDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
+ []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
+ testutil.GitAdd(t, tmpDir, ".trace/settings.json")
+ testutil.GitCommit(t, tmpDir, "enable checkpoints_version 2")
+ t.Chdir(tmpDir)
+
+ repo, err := git.PlainOpen(tmpDir)
+ require.NoError(t, err)
+ return repo
+}
+
+// writeV1Checkpoint writes a minimal checkpoint to the v1 metadata branch.
+func writeV1Checkpoint(t *testing.T, repo *git.Repository, cpID id.CheckpointID, sessionID string) {
+ t.Helper()
+ err := checkpoint.NewGitStore(repo).WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
+ CheckpointID: cpID,
+ SessionID: sessionID,
+ Strategy: "manual-commit",
+ Transcript: redact.AlreadyRedacted([]byte(`{"from":"` + sessionID + `"}`)),
+ AuthorName: "Test",
+ AuthorEmail: "test@test.com",
+ })
+ require.NoError(t, err)
+}
+
+func writeMalformedV1CheckpointWithoutSummary(t *testing.T, repo *git.Repository, cpID id.CheckpointID) {
+ t.Helper()
+ ctx := context.Background()
+
+ blobHash, err := checkpoint.CreateBlobFromContent(repo, []byte("transcript without root metadata"))
+ require.NoError(t, err)
+
+ treeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, map[string]object.TreeEntry{
+ cpID.Path() + "/0/" + paths.TranscriptFileName: {
+ Mode: filemode.Regular,
+ Hash: blobHash,
+ },
+ })
+ require.NoError(t, err)
+
+ commitHash, err := checkpoint.CreateCommit(ctx, repo, treeHash, plumbing.ZeroHash, "malformed v1 checkpoint", "Test", "test@test.com")
+ require.NoError(t, err)
+
+ refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
+ require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
+}
+
+func TestPrintCheckpointsV2MigrationHint(t *testing.T) {
+ t.Run("suppressed when no v1 checkpoints exist", func(t *testing.T) {
+ checkpointsV2MigrationHintOnce = sync.Once{}
+ setupCheckpointsV2CommittedRepo(t)
+
+ restore := captureStderr(t)
+ printCheckpointsV2MigrationHint(context.Background())
+ output := restore()
+
+ assert.Empty(t, output, "hint should not print when there are no v1 checkpoints to migrate")
+ })
+
+ t.Run("suppressed when every v1 checkpoint is already in v2", func(t *testing.T) {
+ checkpointsV2MigrationHintOnce = sync.Once{}
+ repo := setupCheckpointsV2CommittedRepo(t)
+
+ cpID := id.MustCheckpointID("aabbccddeeff")
+ writeV1Checkpoint(t, repo, cpID, "session-1")
+ writeV2Checkpoint(t, repo, cpID, "session-1")
+
+ restore := captureStderr(t)
+ printCheckpointsV2MigrationHint(context.Background())
+ output := restore()
+
+ assert.Empty(t, output, "hint should not print once v2 already mirrors every v1 checkpoint")
+ })
+
+ t.Run("prints when v1 has checkpoints not in v2", func(t *testing.T) {
+ checkpointsV2MigrationHintOnce = sync.Once{}
+ repo := setupCheckpointsV2CommittedRepo(t)
+
+ writeV1Checkpoint(t, repo, id.MustCheckpointID("111111111111"), "session-1")
+
+ restore := captureStderr(t)
+ printCheckpointsV2MigrationHint(context.Background())
+ output := restore()
+
+ assert.Contains(t, output, "trace migrate --checkpoints v2")
+ assert.Contains(t, output, "trace migrate --checkpoints v2 --force")
+ })
+
+ t.Run("prints only once per process", func(t *testing.T) {
+ checkpointsV2MigrationHintOnce = sync.Once{}
+ repo := setupCheckpointsV2CommittedRepo(t)
+
+ writeV1Checkpoint(t, repo, id.MustCheckpointID("222222222222"), "session-2")
+
+ restore := captureStderr(t)
+ printCheckpointsV2MigrationHint(context.Background())
+ printCheckpointsV2MigrationHint(context.Background())
+ output := restore()
+
+ // --force appears in exactly one line, so its count equals the number of
+ // invocations that actually emitted output.
+ forceCount := strings.Count(output, "--force")
+ assert.Equal(t, 1, forceCount, "hint should print exactly once per process")
+ })
+}
+
+func TestHasUnmigratedV1Checkpoints(t *testing.T) {
+ t.Run("false when no v1 checkpoints exist", func(t *testing.T) {
+ setupCheckpointsV2CommittedRepo(t)
+ assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
+ })
+
+ t.Run("false when every v1 checkpoint is in v2", func(t *testing.T) {
+ repo := setupCheckpointsV2CommittedRepo(t)
+ cpID := id.MustCheckpointID("333333333333")
+ writeV1Checkpoint(t, repo, cpID, "session-a")
+ writeV2Checkpoint(t, repo, cpID, "session-a")
+
+ assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
+ })
+
+ t.Run("true when at least one v1 checkpoint is missing from v2", func(t *testing.T) {
+ repo := setupCheckpointsV2CommittedRepo(t)
+ mirrored := id.MustCheckpointID("444444444444")
+ missing := id.MustCheckpointID("555555555555")
+ writeV1Checkpoint(t, repo, mirrored, "session-b")
+ writeV2Checkpoint(t, repo, mirrored, "session-b")
+ writeV1Checkpoint(t, repo, missing, "session-c")
+
+ assert.True(t, hasUnmigratedV1Checkpoints(context.Background()))
+ })
+
+ t.Run("false when only malformed v1 checkpoint entries are missing from v2", func(t *testing.T) {
+ repo := setupCheckpointsV2CommittedRepo(t)
+ writeMalformedV1CheckpointWithoutSummary(t, repo, id.MustCheckpointID("666666666666"))
+
+ assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
+ })
+}
+
+// captureStderr redirects os.Stderr to a pipe and returns a function that restores
+// stderr and returns the captured output. Must be called on the main goroutine
+// (not parallel-safe). Uses t.Cleanup as a safety net to restore stderr and close
+// pipe file descriptors if the test fails or panics before the returned function
+// is called.
+func captureStderr(t *testing.T) func() string {
+ t.Helper()
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+
+ // Safety net: restore stderr and close pipe ends on test failure/panic.
+ // In the normal path the returned function handles cleanup first;
+ // duplicate Close calls return an error that we intentionally ignore.
+ t.Cleanup(func() {
+ os.Stderr = old
+ _ = w.Close()
+ _ = r.Close()
+ })
+
+ return func() string {
+ _ = w.Close()
+ var buf bytes.Buffer
+ _, readErr := buf.ReadFrom(r)
+ require.NoError(t, readErr)
+ _ = r.Close()
+ os.Stderr = old
+ return buf.String()
+ }
+}
+
+// setupBareRemoteWithCheckpointBranch creates a work repo with a checkpoint branch
+// and a bare remote that already has the branch pushed. Returns (workDir, bareDir).
+// Caller must t.Chdir(workDir) before calling push functions.
+func setupBareRemoteWithCheckpointBranch(t *testing.T) (string, string) {
+ t.Helper()
+ ctx := context.Background()
+
+ workDir := setupRepoWithCheckpointBranch(t)
+
+ bareDir := t.TempDir()
+ initCmd := exec.CommandContext(ctx, "git", "init", "--bare")
+ initCmd.Dir = bareDir
+ initCmd.Env = testutil.GitIsolatedEnv()
+ out, err := initCmd.CombinedOutput()
+ require.NoError(t, err, "git init --bare failed: %s", out)
+
+ // Push the checkpoint branch to the bare remote
+ pushCmd := exec.CommandContext(ctx, "git", "push", bareDir, paths.MetadataBranchName)
+ pushCmd.Dir = workDir
+ pushCmd.Env = testutil.GitIsolatedEnv()
+ out, err = pushCmd.CombinedOutput()
+ require.NoError(t, err, "initial push failed: %s", out)
+
+ return workDir, bareDir
+}
+
+// TestDoPushBranch_AlreadyUpToDate verifies that when the remote already has all
+// commits, the output says "already up-to-date" instead of "done".
+//
+// Not parallel: uses t.Chdir() and os.Stderr redirection.
+func TestDoPushBranch_AlreadyUpToDate(t *testing.T) {
+ workDir, bareDir := setupBareRemoteWithCheckpointBranch(t)
+ t.Chdir(workDir)
+
+ restore := captureStderr(t)
+ err := doPushBranch(context.Background(), bareDir, paths.MetadataBranchName)
+ output := restore()
+
+ require.NoError(t, err)
+ assert.Contains(t, output, "already up-to-date", "should indicate nothing was pushed")
+ assert.NotContains(t, output, " done", "should not say 'done' when nothing was pushed")
+}
+
+// TestDoPushBranch_NewContent_SaysDone verifies that when there are new commits
+// to push, the output says "done".
+//
+// Not parallel: uses t.Chdir() and os.Stderr redirection.
+func TestDoPushBranch_NewContent_SaysDone(t *testing.T) {
+ workDir := setupRepoWithCheckpointBranch(t)
+
+ // Create a bare remote with no checkpoint branch yet
+ bareDir := t.TempDir()
+ initCmd := exec.CommandContext(context.Background(), "git", "init", "--bare")
+ initCmd.Dir = bareDir
+ initCmd.Env = testutil.GitIsolatedEnv()
+ out, err := initCmd.CombinedOutput()
+ require.NoError(t, err, "git init --bare failed: %s", out)
+
+ t.Chdir(workDir)
+
+ restore := captureStderr(t)
+ err = doPushBranch(context.Background(), bareDir, paths.MetadataBranchName)
+ output := restore()
+
+ require.NoError(t, err)
+ assert.Contains(t, output, " done", "should say 'done' when new content was pushed")
+ assert.NotContains(t, output, "already up-to-date", "should not say 'already up-to-date' when content was pushed")
+}
+
+func TestIsProtectedRefRejection(t *testing.T) {
+ t.Parallel()
+
+ cases := map[string]struct {
+ output string
+ want bool
+ }{
+ "GH013 marker": {"remote: error: GH013: Repository rule violations found", true},
+ "cannot update phrase": {"remote: error: Cannot update this protected ref.", true},
+ "legacy hook declined": {"! [remote rejected] main -> main (protected branch hook declined)", true},
+ "plain non-fast-forward": {"! [rejected] v1 -> v1 (non-fast-forward)", false},
+ "empty": {"", false},
+ }
+
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, isProtectedRefRejection(tc.output))
+ })
+ }
+}
diff --git a/cli/strategy/push_common_3_test.go b/cli/strategy/push_common_3_test.go
new file mode 100644
index 0000000..94f2e14
--- /dev/null
+++ b/cli/strategy/push_common_3_test.go
@@ -0,0 +1,94 @@
+package strategy
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClassifyPushOutput(t *testing.T) {
+ t.Parallel()
+
+ t.Run("protected-ref wins over 'rejected' keyword", func(t *testing.T) {
+ t.Parallel()
+ output := "remote: error: GH013\n! [remote rejected] v1 -> v1"
+
+ var perr *protectedRefError
+ require.ErrorAs(t, classifyPushOutput(output), &perr)
+ assert.Equal(t, output, perr.output)
+ })
+
+ t.Run("non-fast-forward maps to NFF error", func(t *testing.T) {
+ t.Parallel()
+ err := classifyPushOutput("! [rejected] v1 -> v1 (non-fast-forward)")
+
+ var perr *protectedRefError
+ assert.NotErrorAs(t, err, &perr)
+ require.ErrorIs(t, err, errNonFastForward)
+ assert.EqualError(t, err, "non-fast-forward")
+ })
+
+ t.Run("fetch-first maps to NFF error", func(t *testing.T) {
+ t.Parallel()
+ err := classifyPushOutput("!\trefs/heads/main:refs/heads/main\t[rejected] (fetch first)")
+
+ assert.ErrorIs(t, err, errNonFastForward)
+ })
+
+ t.Run("generic rejected output stays generic", func(t *testing.T) {
+ t.Parallel()
+ err := classifyPushOutput("remote: rejected credentials")
+
+ require.Error(t, err)
+ require.NotErrorIs(t, err, errNonFastForward)
+ assert.ErrorContains(t, err, "push failed: remote: rejected credentials")
+ })
+
+ t.Run("other output is wrapped as push failed", func(t *testing.T) {
+ t.Parallel()
+ err := classifyPushOutput("fatal: Could not resolve host")
+ assert.ErrorContains(t, err, "push failed: fatal: Could not resolve host")
+ })
+
+ t.Run("empty output preserves push error", func(t *testing.T) {
+ t.Parallel()
+ pushErr := errors.New("exit status 128")
+ err := classifyPushFailure(context.Background(), "", pushErr)
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, pushErr)
+ assert.ErrorContains(t, err, "push failed")
+ })
+}
+
+func TestPrintProtectedRefBlock(t *testing.T) {
+ t.Parallel()
+
+ t.Run("remote-name target", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ printProtectedRefBlock(&buf, "trace/checkpoints/v1", "origin")
+
+ out := buf.String()
+ for _, want := range []string{"BLOCKED", "trace/checkpoints/v1", "e.g. GH013", "trace/*", "checkpoints are saved locally", "checkpoint_remote"} {
+ assert.Contains(t, out, want)
+ }
+ banner := strings.Repeat("=", 20)
+ assert.GreaterOrEqual(t, strings.Count(out, banner), 2, "block must be bracketed by banner lines")
+ })
+
+ t.Run("URL target is masked", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ printProtectedRefBlock(&buf, "trace/checkpoints/v1", "git@github.com:org/repo.git")
+
+ out := buf.String()
+ assert.Contains(t, out, displayPushTarget("git@github.com:org/repo.git"))
+ assert.NotContains(t, out, "git@github.com:org/repo.git")
+ })
+}
diff --git a/cli/strategy/push_common_test.go b/cli/strategy/push_common_test.go
index 0e16a73..b82ef45 100644
--- a/cli/strategy/push_common_test.go
+++ b/cli/strategy/push_common_test.go
@@ -1,25 +1,18 @@
package strategy
import (
- "bytes"
"context"
- "errors"
"os"
"os/exec"
"path/filepath"
- "strings"
- "sync"
"testing"
"github.com/GrayCodeAI/trace/cli/checkpoint"
- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/paths"
"github.com/GrayCodeAI/trace/cli/testutil"
- "github.com/GrayCodeAI/trace/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
- "github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/assert"
@@ -733,872 +726,3 @@ func TestFetchAndRebase_NonOriginRemote_ReconcilesFetchedRef(t *testing.T) {
assert.Contains(t, entries, "aa/aaaaaaaaaa/metadata.json", "remote checkpoint should be preserved")
assert.Contains(t, entries, "cc/cccccccccc/metadata.json", "local checkpoint should be preserved")
}
-
-// TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef verifies that URL
-// targets reconcile against the temporary fetched ref instead of any origin
-// tracking state.
-//
-// Not parallel: uses t.Chdir() (required for OpenRepository).
-func TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef(t *testing.T) {
- ctx := context.Background()
- branchName := paths.MetadataBranchName
-
- bareDir := t.TempDir()
- setupDir := t.TempDir()
- gitRun := func(dir string, args ...string) {
- t.Helper()
- cmd := exec.CommandContext(ctx, "git", args...)
- cmd.Dir = dir
- cmd.Env = testutil.GitIsolatedEnv()
- out, err := cmd.CombinedOutput()
- require.NoError(t, err, "git %v in %s failed: %s", args, dir, out)
- }
-
- gitRun(bareDir, "init", "--bare", "-b", "main")
- gitRun(setupDir, "clone", bareDir, ".")
- gitRun(setupDir, "config", "user.email", "test@test.com")
- gitRun(setupDir, "config", "user.name", "Test User")
- gitRun(setupDir, "config", "commit.gpgsign", "false")
- require.NoError(t, os.WriteFile(filepath.Join(setupDir, "README.md"), []byte("# Test"), 0o644))
- gitRun(setupDir, "add", ".")
- gitRun(setupDir, "commit", "-m", "init")
- gitRun(setupDir, "push", "origin", "main")
-
- gitRun(setupDir, "checkout", "--orphan", branchName)
- gitRun(setupDir, "rm", "-rf", ".")
- baseDir := filepath.Join(setupDir, "aa", "aaaaaaaaaa")
- require.NoError(t, os.MkdirAll(baseDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(baseDir, "metadata.json"),
- []byte(`{"checkpoint_id":"aaaaaaaaaaaa"}`), 0o644))
- gitRun(setupDir, "add", ".")
- gitRun(setupDir, "commit", "-m", "Checkpoint: aaaaaaaaaaaa")
- gitRun(setupDir, "push", "origin", branchName)
- gitRun(setupDir, "checkout", "main")
-
- cloneDir := filepath.Join(t.TempDir(), "clone")
- require.NoError(t, os.MkdirAll(cloneDir, 0o755))
- gitRun(cloneDir, "clone", bareDir, ".")
- gitRun(cloneDir, "config", "user.email", "test@test.com")
- gitRun(cloneDir, "config", "user.name", "Test User")
- gitRun(cloneDir, "config", "commit.gpgsign", "false")
- gitRun(cloneDir, "branch", branchName, "origin/"+branchName)
-
- gitRun(cloneDir, "checkout", "--orphan", "temp-orphan")
- gitRun(cloneDir, "rm", "-rf", ".")
- localDir := filepath.Join(cloneDir, "cc", "cccccccccc")
- require.NoError(t, os.MkdirAll(localDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"),
- []byte(`{"checkpoint_id":"cccccccccccc"}`), 0o644))
- gitRun(cloneDir, "add", ".")
- gitRun(cloneDir, "commit", "-m", "Checkpoint: cccccccccccc")
- gitRun(cloneDir, "branch", "-f", branchName, "temp-orphan")
- gitRun(cloneDir, "checkout", "main")
-
- repo, err := git.PlainOpen(cloneDir)
- require.NoError(t, err)
- localRefBeforeFetch, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- require.NoError(t, err)
- staleOriginRef := plumbing.NewHashReference(
- plumbing.NewRemoteReferenceName("origin", branchName),
- localRefBeforeFetch.Hash(),
- )
- require.NoError(t, repo.Storer.SetReference(staleOriginRef))
-
- t.Chdir(cloneDir)
-
- err = fetchAndRebaseSessionsCommon(ctx, "file://"+bareDir, branchName)
- require.NoError(t, err)
-
- repo, err = git.PlainOpen(cloneDir)
- require.NoError(t, err)
-
- localRef, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- require.NoError(t, err)
-
- tipCommit, err := repo.CommitObject(localRef.Hash())
- require.NoError(t, err)
- require.Len(t, tipCommit.ParentHashes, 1)
-
- tree, err := tipCommit.Tree()
- require.NoError(t, err)
-
- entries := make(map[string]object.TreeEntry)
- require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
- assert.Contains(t, entries, "aa/aaaaaaaaaa/metadata.json", "remote checkpoint should be preserved")
- assert.Contains(t, entries, "cc/cccccccccc/metadata.json", "local checkpoint should be preserved")
-
- _, err = repo.Reference(plumbing.ReferenceName("refs/trace-fetch-tmp/"+branchName), true)
- assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up")
-}
-
-// TestFetchAndRebase_FlaggedOriginTarget_UsesTempRef verifies that enabling
-// filtered_fetches for a normal remote-name target follows the temp-ref
-// path and still cleans up after rebasing.
-//
-// Not parallel: uses t.Chdir() (required for OpenRepository).
-func TestFetchAndRebase_FlaggedOriginTarget_UsesTempRef(t *testing.T) {
- ctx := context.Background()
- branchName := paths.MetadataBranchName
-
- bareDir := t.TempDir()
- setupDir := t.TempDir()
- gitRun := func(dir string, args ...string) {
- t.Helper()
- cmd := exec.CommandContext(ctx, "git", args...)
- cmd.Dir = dir
- cmd.Env = testutil.GitIsolatedEnv()
- out, err := cmd.CombinedOutput()
- require.NoError(t, err, "git %v in %s failed: %s", args, dir, out)
- }
-
- gitRun(bareDir, "init", "--bare", "-b", "main")
- gitRun(setupDir, "clone", bareDir, ".")
- gitRun(setupDir, "config", "user.email", "test@test.com")
- gitRun(setupDir, "config", "user.name", "Test User")
- gitRun(setupDir, "config", "commit.gpgsign", "false")
- require.NoError(t, os.WriteFile(filepath.Join(setupDir, "README.md"), []byte("# Test"), 0o644))
- gitRun(setupDir, "add", ".")
- gitRun(setupDir, "commit", "-m", "init")
- gitRun(setupDir, "push", "origin", "main")
-
- gitRun(setupDir, "checkout", "--orphan", branchName)
- gitRun(setupDir, "rm", "-rf", ".")
- baseDir := filepath.Join(setupDir, "aa", "aaaaaaaaaa")
- require.NoError(t, os.MkdirAll(baseDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(baseDir, "metadata.json"),
- []byte(`{"checkpoint_id":"aaaaaaaaaaaa"}`), 0o644))
- gitRun(setupDir, "add", ".")
- gitRun(setupDir, "commit", "-m", "Checkpoint: aaaaaaaaaaaa")
- gitRun(setupDir, "push", "origin", branchName)
- gitRun(setupDir, "checkout", "main")
-
- cloneDir := filepath.Join(t.TempDir(), "clone")
- require.NoError(t, os.MkdirAll(cloneDir, 0o755))
- gitRun(cloneDir, "clone", bareDir, ".")
- gitRun(cloneDir, "config", "user.email", "test@test.com")
- gitRun(cloneDir, "config", "user.name", "Test User")
- gitRun(cloneDir, "config", "commit.gpgsign", "false")
- gitRun(cloneDir, "branch", branchName, "origin/"+branchName)
- require.NoError(t, os.MkdirAll(filepath.Join(cloneDir, ".trace"), 0o755))
- require.NoError(t, os.WriteFile(
- filepath.Join(cloneDir, ".trace", "settings.json"),
- []byte(`{"enabled": true, "strategy_options": {"filtered_fetches": true}}`),
- 0o644,
- ))
-
- gitRun(cloneDir, "checkout", "--orphan", "temp-orphan")
- gitRun(cloneDir, "rm", "-rf", ".")
- localDir := filepath.Join(cloneDir, "cc", "cccccccccc")
- require.NoError(t, os.MkdirAll(localDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"),
- []byte(`{"checkpoint_id":"cccccccccccc"}`), 0o644))
- gitRun(cloneDir, "add", ".")
- gitRun(cloneDir, "commit", "-m", "Checkpoint: cccccccccccc")
- gitRun(cloneDir, "branch", "-f", branchName, "temp-orphan")
- gitRun(cloneDir, "checkout", "main")
-
- repo, err := git.PlainOpen(cloneDir)
- require.NoError(t, err)
- localRefBeforeFetch, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- require.NoError(t, err)
- staleOriginRef := plumbing.NewHashReference(
- plumbing.NewRemoteReferenceName("origin", branchName),
- localRefBeforeFetch.Hash(),
- )
- require.NoError(t, repo.Storer.SetReference(staleOriginRef))
-
- t.Chdir(cloneDir)
-
- err = fetchAndRebaseSessionsCommon(ctx, "origin", branchName)
- require.NoError(t, err)
-
- repo, err = git.PlainOpen(cloneDir)
- require.NoError(t, err)
-
- localRef, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- require.NoError(t, err)
-
- tipCommit, err := repo.CommitObject(localRef.Hash())
- require.NoError(t, err)
- require.Len(t, tipCommit.ParentHashes, 1)
-
- tree, err := tipCommit.Tree()
- require.NoError(t, err)
-
- entries := make(map[string]object.TreeEntry)
- require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries))
- assert.Contains(t, entries, "aa/aaaaaaaaaa/metadata.json", "remote checkpoint should be preserved")
- assert.Contains(t, entries, "cc/cccccccccc/metadata.json", "local checkpoint should be preserved")
-
- _, err = repo.Reference(plumbing.ReferenceName("refs/trace-fetch-tmp/"+branchName), true)
- assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up")
-}
-
-// TestIsCheckpointRemoteCommitted verifies that the discoverability check reads
-// the committed content of .trace/settings.json at HEAD, not just tracking status.
-// Not parallel: uses t.Chdir().
-func TestIsCheckpointRemoteCommitted(t *testing.T) {
- checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}`
-
- t.Run("false when settings.json not committed", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Create .trace/settings.json with checkpoint_remote but don't commit it
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
-
- t.Chdir(tmpDir)
- assert.False(t, isCheckpointRemoteCommitted(context.Background()))
- })
-
- t.Run("false when committed settings.json has no checkpoint_remote", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Commit settings.json without checkpoint_remote
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings")
-
- t.Chdir(tmpDir)
- assert.False(t, isCheckpointRemoteCommitted(context.Background()))
- })
-
- t.Run("true when committed settings.json has checkpoint_remote", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Commit settings.json with checkpoint_remote
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings")
-
- t.Chdir(tmpDir)
- assert.True(t, isCheckpointRemoteCommitted(context.Background()))
- })
-
- t.Run("false when checkpoint_remote only in local changes", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Commit settings.json without checkpoint_remote
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings without remote")
-
- // Now add checkpoint_remote locally but don't commit
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
-
- t.Chdir(tmpDir)
- assert.False(t, isCheckpointRemoteCommitted(context.Background()),
- "uncommitted checkpoint_remote should not count as discoverable")
- })
-
- t.Run("works from subdirectory", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings")
-
- subDir := filepath.Join(tmpDir, "subdir")
- require.NoError(t, os.MkdirAll(subDir, 0o755))
- t.Chdir(subDir)
- assert.True(t, isCheckpointRemoteCommitted(context.Background()),
- "should detect committed checkpoint_remote from subdirectory")
- })
-}
-
-// TestPrintSettingsCommitHint verifies the hint only prints for URL targets
-// when checkpoint_remote is not discoverable from committed settings, and only
-// once per process via sync.Once.
-// Not parallel: uses t.Chdir() and resets package-level settingsHintOnce.
-func TestPrintSettingsCommitHint(t *testing.T) {
- checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}`
-
- t.Run("no hint for non-URL target", func(t *testing.T) {
- settingsHintOnce = sync.Once{}
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
- t.Chdir(tmpDir)
-
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- printSettingsCommitHint(context.Background(), "origin")
-
- w.Close()
- var buf bytes.Buffer
- if _, readErr := buf.ReadFrom(r); readErr != nil {
- t.Fatalf("read pipe: %v", readErr)
- }
- os.Stderr = old
-
- assert.Empty(t, buf.String(), "should not print hint for non-URL target")
- })
-
- t.Run("hint when checkpoint_remote not in committed settings", func(t *testing.T) {
- settingsHintOnce = sync.Once{}
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Create .trace/settings.json but don't commit it
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
- t.Chdir(tmpDir)
-
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
-
- w.Close()
- var buf bytes.Buffer
- if _, readErr := buf.ReadFrom(r); readErr != nil {
- t.Fatalf("read pipe: %v", readErr)
- }
- os.Stderr = old
-
- assert.Contains(t, buf.String(), "does not contain checkpoint_remote")
- assert.Contains(t, buf.String(), "trace.io will not be able to discover")
- })
-
- t.Run("hint when committed settings lacks checkpoint_remote", func(t *testing.T) {
- settingsHintOnce = sync.Once{}
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Commit settings.json without checkpoint_remote
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{}`), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings")
- t.Chdir(tmpDir)
-
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
-
- w.Close()
- var buf bytes.Buffer
- if _, readErr := buf.ReadFrom(r); readErr != nil {
- t.Fatalf("read pipe: %v", readErr)
- }
- os.Stderr = old
-
- assert.Contains(t, buf.String(), "does not contain checkpoint_remote",
- "should warn when committed settings.json exists but lacks checkpoint_remote")
- })
-
- t.Run("no hint when checkpoint_remote is committed", func(t *testing.T) {
- settingsHintOnce = sync.Once{}
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- // Commit settings.json with checkpoint_remote
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(checkpointRemoteSettings), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "add settings with checkpoint remote")
- t.Chdir(tmpDir)
-
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
-
- w.Close()
- var buf bytes.Buffer
- if _, readErr := buf.ReadFrom(r); readErr != nil {
- t.Fatalf("read pipe: %v", readErr)
- }
- os.Stderr = old
-
- assert.Empty(t, buf.String(), "should not print hint when checkpoint_remote is committed")
- })
-
- t.Run("prints only once per process", func(t *testing.T) {
- settingsHintOnce = sync.Once{}
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
- t.Chdir(tmpDir)
-
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- // Call twice — should only print once
- printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
- printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git")
-
- w.Close()
- var buf bytes.Buffer
- if _, readErr := buf.ReadFrom(r); readErr != nil {
- t.Fatalf("read pipe: %v", readErr)
- }
- os.Stderr = old
-
- count := bytes.Count(buf.Bytes(), []byte("does not contain checkpoint_remote"))
- assert.Equal(t, 1, count, "hint should print exactly once, got %d", count)
- })
-}
-
-func TestIsCheckpointsVersion2Committed(t *testing.T) {
- t.Run("false when settings.json not committed", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
-
- t.Chdir(tmpDir)
- assert.False(t, isCheckpointsVersion2Committed(context.Background()))
- })
-
- t.Run("true when checkpoints_version 2 is committed", func(t *testing.T) {
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "enable checkpoints_version 2")
-
- t.Chdir(tmpDir)
- assert.True(t, isCheckpointsVersion2Committed(context.Background()))
- })
-}
-
-// setupCheckpointsV2CommittedRepo creates a temp repo with checkpoints_version: 2
-// set in the committed .trace/settings.json and chdirs into it. Returns an opened
-// *git.Repository for populating checkpoints.
-func setupCheckpointsV2CommittedRepo(t *testing.T) *git.Repository {
- t.Helper()
-
- tmpDir := t.TempDir()
- testutil.InitRepo(t, tmpDir)
- testutil.WriteFile(t, tmpDir, "f.txt", "init")
- testutil.GitAdd(t, tmpDir, "f.txt")
- testutil.GitCommit(t, tmpDir, "init")
-
- traceDir := filepath.Join(tmpDir, ".trace")
- require.NoError(t, os.MkdirAll(traceDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(traceDir, "settings.json"),
- []byte(`{"strategy_options":{"checkpoints_version":2}}`), 0o644))
- testutil.GitAdd(t, tmpDir, ".trace/settings.json")
- testutil.GitCommit(t, tmpDir, "enable checkpoints_version 2")
- t.Chdir(tmpDir)
-
- repo, err := git.PlainOpen(tmpDir)
- require.NoError(t, err)
- return repo
-}
-
-// writeV1Checkpoint writes a minimal checkpoint to the v1 metadata branch.
-func writeV1Checkpoint(t *testing.T, repo *git.Repository, cpID id.CheckpointID, sessionID string) {
- t.Helper()
- err := checkpoint.NewGitStore(repo).WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
- CheckpointID: cpID,
- SessionID: sessionID,
- Strategy: "manual-commit",
- Transcript: redact.AlreadyRedacted([]byte(`{"from":"` + sessionID + `"}`)),
- AuthorName: "Test",
- AuthorEmail: "test@test.com",
- })
- require.NoError(t, err)
-}
-
-func writeMalformedV1CheckpointWithoutSummary(t *testing.T, repo *git.Repository, cpID id.CheckpointID) {
- t.Helper()
- ctx := context.Background()
-
- blobHash, err := checkpoint.CreateBlobFromContent(repo, []byte("transcript without root metadata"))
- require.NoError(t, err)
-
- treeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, map[string]object.TreeEntry{
- cpID.Path() + "/0/" + paths.TranscriptFileName: {
- Mode: filemode.Regular,
- Hash: blobHash,
- },
- })
- require.NoError(t, err)
-
- commitHash, err := checkpoint.CreateCommit(ctx, repo, treeHash, plumbing.ZeroHash, "malformed v1 checkpoint", "Test", "test@test.com")
- require.NoError(t, err)
-
- refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
- require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)))
-}
-
-func TestPrintCheckpointsV2MigrationHint(t *testing.T) {
- t.Run("suppressed when no v1 checkpoints exist", func(t *testing.T) {
- checkpointsV2MigrationHintOnce = sync.Once{}
- setupCheckpointsV2CommittedRepo(t)
-
- restore := captureStderr(t)
- printCheckpointsV2MigrationHint(context.Background())
- output := restore()
-
- assert.Empty(t, output, "hint should not print when there are no v1 checkpoints to migrate")
- })
-
- t.Run("suppressed when every v1 checkpoint is already in v2", func(t *testing.T) {
- checkpointsV2MigrationHintOnce = sync.Once{}
- repo := setupCheckpointsV2CommittedRepo(t)
-
- cpID := id.MustCheckpointID("aabbccddeeff")
- writeV1Checkpoint(t, repo, cpID, "session-1")
- writeV2Checkpoint(t, repo, cpID, "session-1")
-
- restore := captureStderr(t)
- printCheckpointsV2MigrationHint(context.Background())
- output := restore()
-
- assert.Empty(t, output, "hint should not print once v2 already mirrors every v1 checkpoint")
- })
-
- t.Run("prints when v1 has checkpoints not in v2", func(t *testing.T) {
- checkpointsV2MigrationHintOnce = sync.Once{}
- repo := setupCheckpointsV2CommittedRepo(t)
-
- writeV1Checkpoint(t, repo, id.MustCheckpointID("111111111111"), "session-1")
-
- restore := captureStderr(t)
- printCheckpointsV2MigrationHint(context.Background())
- output := restore()
-
- assert.Contains(t, output, "trace migrate --checkpoints v2")
- assert.Contains(t, output, "trace migrate --checkpoints v2 --force")
- })
-
- t.Run("prints only once per process", func(t *testing.T) {
- checkpointsV2MigrationHintOnce = sync.Once{}
- repo := setupCheckpointsV2CommittedRepo(t)
-
- writeV1Checkpoint(t, repo, id.MustCheckpointID("222222222222"), "session-2")
-
- restore := captureStderr(t)
- printCheckpointsV2MigrationHint(context.Background())
- printCheckpointsV2MigrationHint(context.Background())
- output := restore()
-
- // --force appears in exactly one line, so its count equals the number of
- // invocations that actually emitted output.
- forceCount := strings.Count(output, "--force")
- assert.Equal(t, 1, forceCount, "hint should print exactly once per process")
- })
-}
-
-func TestHasUnmigratedV1Checkpoints(t *testing.T) {
- t.Run("false when no v1 checkpoints exist", func(t *testing.T) {
- setupCheckpointsV2CommittedRepo(t)
- assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
- })
-
- t.Run("false when every v1 checkpoint is in v2", func(t *testing.T) {
- repo := setupCheckpointsV2CommittedRepo(t)
- cpID := id.MustCheckpointID("333333333333")
- writeV1Checkpoint(t, repo, cpID, "session-a")
- writeV2Checkpoint(t, repo, cpID, "session-a")
-
- assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
- })
-
- t.Run("true when at least one v1 checkpoint is missing from v2", func(t *testing.T) {
- repo := setupCheckpointsV2CommittedRepo(t)
- mirrored := id.MustCheckpointID("444444444444")
- missing := id.MustCheckpointID("555555555555")
- writeV1Checkpoint(t, repo, mirrored, "session-b")
- writeV2Checkpoint(t, repo, mirrored, "session-b")
- writeV1Checkpoint(t, repo, missing, "session-c")
-
- assert.True(t, hasUnmigratedV1Checkpoints(context.Background()))
- })
-
- t.Run("false when only malformed v1 checkpoint entries are missing from v2", func(t *testing.T) {
- repo := setupCheckpointsV2CommittedRepo(t)
- writeMalformedV1CheckpointWithoutSummary(t, repo, id.MustCheckpointID("666666666666"))
-
- assert.False(t, hasUnmigratedV1Checkpoints(context.Background()))
- })
-}
-
-// captureStderr redirects os.Stderr to a pipe and returns a function that restores
-// stderr and returns the captured output. Must be called on the main goroutine
-// (not parallel-safe). Uses t.Cleanup as a safety net to restore stderr and close
-// pipe file descriptors if the test fails or panics before the returned function
-// is called.
-func captureStderr(t *testing.T) func() string {
- t.Helper()
- old := os.Stderr
- r, w, err := os.Pipe()
- require.NoError(t, err)
- os.Stderr = w
-
- // Safety net: restore stderr and close pipe ends on test failure/panic.
- // In the normal path the returned function handles cleanup first;
- // duplicate Close calls return an error that we intentionally ignore.
- t.Cleanup(func() {
- os.Stderr = old
- _ = w.Close()
- _ = r.Close()
- })
-
- return func() string {
- _ = w.Close()
- var buf bytes.Buffer
- _, readErr := buf.ReadFrom(r)
- require.NoError(t, readErr)
- _ = r.Close()
- os.Stderr = old
- return buf.String()
- }
-}
-
-// setupBareRemoteWithCheckpointBranch creates a work repo with a checkpoint branch
-// and a bare remote that already has the branch pushed. Returns (workDir, bareDir).
-// Caller must t.Chdir(workDir) before calling push functions.
-func setupBareRemoteWithCheckpointBranch(t *testing.T) (string, string) {
- t.Helper()
- ctx := context.Background()
-
- workDir := setupRepoWithCheckpointBranch(t)
-
- bareDir := t.TempDir()
- initCmd := exec.CommandContext(ctx, "git", "init", "--bare")
- initCmd.Dir = bareDir
- initCmd.Env = testutil.GitIsolatedEnv()
- out, err := initCmd.CombinedOutput()
- require.NoError(t, err, "git init --bare failed: %s", out)
-
- // Push the checkpoint branch to the bare remote
- pushCmd := exec.CommandContext(ctx, "git", "push", bareDir, paths.MetadataBranchName)
- pushCmd.Dir = workDir
- pushCmd.Env = testutil.GitIsolatedEnv()
- out, err = pushCmd.CombinedOutput()
- require.NoError(t, err, "initial push failed: %s", out)
-
- return workDir, bareDir
-}
-
-// TestDoPushBranch_AlreadyUpToDate verifies that when the remote already has all
-// commits, the output says "already up-to-date" instead of "done".
-//
-// Not parallel: uses t.Chdir() and os.Stderr redirection.
-func TestDoPushBranch_AlreadyUpToDate(t *testing.T) {
- workDir, bareDir := setupBareRemoteWithCheckpointBranch(t)
- t.Chdir(workDir)
-
- restore := captureStderr(t)
- err := doPushBranch(context.Background(), bareDir, paths.MetadataBranchName)
- output := restore()
-
- require.NoError(t, err)
- assert.Contains(t, output, "already up-to-date", "should indicate nothing was pushed")
- assert.NotContains(t, output, " done", "should not say 'done' when nothing was pushed")
-}
-
-// TestDoPushBranch_NewContent_SaysDone verifies that when there are new commits
-// to push, the output says "done".
-//
-// Not parallel: uses t.Chdir() and os.Stderr redirection.
-func TestDoPushBranch_NewContent_SaysDone(t *testing.T) {
- workDir := setupRepoWithCheckpointBranch(t)
-
- // Create a bare remote with no checkpoint branch yet
- bareDir := t.TempDir()
- initCmd := exec.CommandContext(context.Background(), "git", "init", "--bare")
- initCmd.Dir = bareDir
- initCmd.Env = testutil.GitIsolatedEnv()
- out, err := initCmd.CombinedOutput()
- require.NoError(t, err, "git init --bare failed: %s", out)
-
- t.Chdir(workDir)
-
- restore := captureStderr(t)
- err = doPushBranch(context.Background(), bareDir, paths.MetadataBranchName)
- output := restore()
-
- require.NoError(t, err)
- assert.Contains(t, output, " done", "should say 'done' when new content was pushed")
- assert.NotContains(t, output, "already up-to-date", "should not say 'already up-to-date' when content was pushed")
-}
-
-func TestIsProtectedRefRejection(t *testing.T) {
- t.Parallel()
-
- cases := map[string]struct {
- output string
- want bool
- }{
- "GH013 marker": {"remote: error: GH013: Repository rule violations found", true},
- "cannot update phrase": {"remote: error: Cannot update this protected ref.", true},
- "legacy hook declined": {"! [remote rejected] main -> main (protected branch hook declined)", true},
- "plain non-fast-forward": {"! [rejected] v1 -> v1 (non-fast-forward)", false},
- "empty": {"", false},
- }
-
- for name, tc := range cases {
- t.Run(name, func(t *testing.T) {
- t.Parallel()
- assert.Equal(t, tc.want, isProtectedRefRejection(tc.output))
- })
- }
-}
-
-func TestClassifyPushOutput(t *testing.T) {
- t.Parallel()
-
- t.Run("protected-ref wins over 'rejected' keyword", func(t *testing.T) {
- t.Parallel()
- output := "remote: error: GH013\n! [remote rejected] v1 -> v1"
-
- var perr *protectedRefError
- require.ErrorAs(t, classifyPushOutput(output), &perr)
- assert.Equal(t, output, perr.output)
- })
-
- t.Run("non-fast-forward maps to NFF error", func(t *testing.T) {
- t.Parallel()
- err := classifyPushOutput("! [rejected] v1 -> v1 (non-fast-forward)")
-
- var perr *protectedRefError
- assert.NotErrorAs(t, err, &perr)
- require.ErrorIs(t, err, errNonFastForward)
- assert.EqualError(t, err, "non-fast-forward")
- })
-
- t.Run("fetch-first maps to NFF error", func(t *testing.T) {
- t.Parallel()
- err := classifyPushOutput("!\trefs/heads/main:refs/heads/main\t[rejected] (fetch first)")
-
- assert.ErrorIs(t, err, errNonFastForward)
- })
-
- t.Run("generic rejected output stays generic", func(t *testing.T) {
- t.Parallel()
- err := classifyPushOutput("remote: rejected credentials")
-
- require.Error(t, err)
- require.NotErrorIs(t, err, errNonFastForward)
- assert.ErrorContains(t, err, "push failed: remote: rejected credentials")
- })
-
- t.Run("other output is wrapped as push failed", func(t *testing.T) {
- t.Parallel()
- err := classifyPushOutput("fatal: Could not resolve host")
- assert.ErrorContains(t, err, "push failed: fatal: Could not resolve host")
- })
-
- t.Run("empty output preserves push error", func(t *testing.T) {
- t.Parallel()
- pushErr := errors.New("exit status 128")
- err := classifyPushFailure(context.Background(), "", pushErr)
-
- require.Error(t, err)
- require.ErrorIs(t, err, pushErr)
- assert.ErrorContains(t, err, "push failed")
- })
-}
-
-func TestPrintProtectedRefBlock(t *testing.T) {
- t.Parallel()
-
- t.Run("remote-name target", func(t *testing.T) {
- t.Parallel()
- var buf bytes.Buffer
- printProtectedRefBlock(&buf, "trace/checkpoints/v1", "origin")
-
- out := buf.String()
- for _, want := range []string{"BLOCKED", "trace/checkpoints/v1", "e.g. GH013", "trace/*", "checkpoints are saved locally", "checkpoint_remote"} {
- assert.Contains(t, out, want)
- }
- banner := strings.Repeat("=", 20)
- assert.GreaterOrEqual(t, strings.Count(out, banner), 2, "block must be bracketed by banner lines")
- })
-
- t.Run("URL target is masked", func(t *testing.T) {
- t.Parallel()
- var buf bytes.Buffer
- printProtectedRefBlock(&buf, "trace/checkpoints/v1", "git@github.com:org/repo.git")
-
- out := buf.String()
- assert.Contains(t, out, displayPushTarget("git@github.com:org/repo.git"))
- assert.NotContains(t, out, "git@github.com:org/repo.git")
- })
-}
diff --git a/cli/summarize/summarize_2_test.go b/cli/summarize/summarize_2_test.go
new file mode 100644
index 0000000..b5f6869
--- /dev/null
+++ b/cli/summarize/summarize_2_test.go
@@ -0,0 +1,351 @@
+package summarize
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/GrayCodeAI/trace/cli/agent"
+ "github.com/GrayCodeAI/trace/cli/agent/types"
+ "github.com/GrayCodeAI/trace/redact"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildCondensedTranscriptFromBytes_Codex_ExecCommandDetail(t *testing.T) {
+ t.Parallel()
+
+ codexTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
+{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Running command."}]}}
+{"timestamp":"t3","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"ls -la\",\"workdir\":\"/repo\"}"}}
+{"timestamp":"t4","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"total 0"}}
+`)
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(codexTranscript), agent.AgentTypeCodex)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Find the tool entry
+ var toolEntry *Entry
+ for i := range entries {
+ if entries[i].Type == EntryTypeTool {
+ toolEntry = &entries[i]
+ break
+ }
+ }
+ require.NotNil(t, toolEntry, "no tool entry found in entries: %#v", entries)
+ if toolEntry.ToolName != "exec_command" {
+ t.Fatalf("expected exec_command, got %q", toolEntry.ToolName)
+ }
+ if toolEntry.ToolDetail != "ls -la" {
+ t.Fatalf("expected tool detail 'ls -la', got %q", toolEntry.ToolDetail)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T) {
+ // OpenCode export JSON format
+ ocExportJSON := `{
+ "info": {"id": "test-session"},
+ "messages": [
+ {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Fix the bug in main.go"}]},
+ {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": [{"type": "text", "text": "I'll fix the bug."}]}
+ ]
+ }`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Fatalf("expected 2 entries, got %d", len(entries))
+ }
+
+ if entries[0].Type != EntryTypeUser {
+ t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
+ }
+ if entries[0].Content != "Fix the bug in main.go" {
+ t.Errorf("entry 0: unexpected content: %s", entries[0].Content)
+ }
+
+ if entries[1].Type != EntryTypeAssistant {
+ t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
+ }
+ if entries[1].Content != "I'll fix the bug." {
+ t.Errorf("entry 1: unexpected content: %s", entries[1].Content)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) {
+ // OpenCode export JSON format with tool calls
+ ocExportJSON := `{
+ "info": {"id": "test-session"},
+ "messages": [
+ {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Edit main.go"}]},
+ {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": [
+ {"type": "text", "text": "Editing now."},
+ {"type": "tool", "tool": "edit", "callID": "call-1", "state": {"status": "completed", "input": {"filePath": "main.go"}, "output": "Applied"}},
+ {"type": "tool", "tool": "bash", "callID": "call-2", "state": {"status": "completed", "input": {"command": "go test ./..."}, "output": "PASS"}}
+ ]}
+ ]
+ }`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // user + assistant + 2 tool calls
+ if len(entries) != 4 {
+ t.Fatalf("expected 4 entries, got %d", len(entries))
+ }
+
+ if entries[2].Type != EntryTypeTool {
+ t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type)
+ }
+ if entries[2].ToolName != "edit" {
+ t.Errorf("entry 2: expected tool name edit, got %s", entries[2].ToolName)
+ }
+ if entries[2].ToolDetail != testMainGoFile {
+ t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail)
+ }
+
+ if entries[3].ToolName != "bash" {
+ t.Errorf("entry 3: expected tool name bash, got %s", entries[3].ToolName)
+ }
+ if entries[3].ToolDetail != "go test ./..." {
+ t.Errorf("entry 3: expected tool detail 'go test ./...', got %s", entries[3].ToolDetail)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_OpenCodeSkipsEmptyContent(t *testing.T) {
+ // OpenCode export JSON format with empty content messages
+ ocExportJSON := `{
+ "info": {"id": "test-session"},
+ "messages": [
+ {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": []},
+ {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": []},
+ {"info": {"id": "msg-3", "role": "user", "time": {"created": 1708300010}}, "parts": [{"type": "text", "text": "Real prompt"}]}
+ ]
+ }`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Fatalf("expected 1 entry (empty content skipped), got %d", len(entries))
+ }
+ if entries[0].Content != "Real prompt" {
+ t.Errorf("expected 'Real prompt', got %s", entries[0].Content)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSON(t *testing.T) {
+ // Invalid JSON now returns an error (not silently skipped like JSONL)
+ _, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("not json")), agent.AgentTypeOpenCode)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_CompactTranscriptFallback(t *testing.T) {
+ compactJSONL := `{"v":1,"agent":"pi","cli_version":"test","type":"user","ts":"2026-01-01T00:00:00Z","content":[{"text":"Create bye.txt"}]}
+{"v":1,"agent":"pi","cli_version":"test","type":"assistant","ts":"2026-01-01T00:00:01Z","content":[{"type":"tool_use","id":"tc1","name":"Write","input":{"path":"bye.txt"}},{"type":"text","text":"Created bye.txt"}]}
+`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(compactJSONL)), types.AgentType("Pi"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(entries) != 3 {
+ t.Fatalf("expected 3 entries, got %d", len(entries))
+ }
+ if entries[0].Type != EntryTypeUser || entries[0].Content != "Create bye.txt" {
+ t.Fatalf("unexpected first entry: %+v", entries[0])
+ }
+ if entries[1].Type != EntryTypeTool || entries[1].ToolName != "Write" || entries[1].ToolDetail != "bye.txt" {
+ t.Fatalf("unexpected tool entry: %+v", entries[1])
+ }
+ if entries[2].Type != EntryTypeAssistant || entries[2].Content != "Created bye.txt" {
+ t.Fatalf("unexpected assistant entry: %+v", entries[2])
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_CursorRoleBasedJSONL(t *testing.T) {
+ // Cursor transcripts use "role" instead of "type" and wrap user text in tags.
+ // The transcript parser normalizes role→type, so condensation should work.
+ cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"\nhello\n"}]}}
+{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}}
+{"role":"user","message":{"content":[{"type":"text","text":"\nadd one to a file and commit\n"}]}}
+{"role":"assistant","message":{"content":[{"type":"text","text":"Created one.txt with one and committed."}]}}
+`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(cursorJSONL)), agent.AgentTypeCursor)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(entries) == 0 {
+ t.Fatal("expected non-empty entries for Cursor transcript, got 0 (role→type normalization may be broken)")
+ }
+
+ // Should have 4 entries: 2 user + 2 assistant
+ if len(entries) != 4 {
+ t.Fatalf("expected 4 entries, got %d", len(entries))
+ }
+
+ if entries[0].Type != EntryTypeUser {
+ t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
+ }
+ if !strings.Contains(entries[0].Content, "hello") {
+ t.Errorf("entry 0: expected content containing 'hello', got %q", entries[0].Content)
+ }
+
+ if entries[1].Type != EntryTypeAssistant {
+ t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
+ }
+ if entries[1].Content != "Hi there!" {
+ t.Errorf("entry 1: expected 'Hi there!', got %q", entries[1].Content)
+ }
+
+ if entries[2].Type != EntryTypeUser {
+ t.Errorf("entry 2: expected type %s, got %s", EntryTypeUser, entries[2].Type)
+ }
+
+ if entries[3].Type != EntryTypeAssistant {
+ t.Errorf("entry 3: expected type %s, got %s", EntryTypeAssistant, entries[3].Type)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_CursorNoToolUseBlocks(t *testing.T) {
+ // Cursor transcripts have no tool_use blocks — only text content.
+ // This verifies we get entries (not an empty result) even without tool calls.
+ cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"write a poem"}]}}
+{"role":"assistant","message":{"content":[{"type":"text","text":"Here is a poem about code."}]}}
+`
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(cursorJSONL)), agent.AgentTypeCursor)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Fatalf("expected 2 entries, got %d", len(entries))
+ }
+
+ // No tool entries should appear
+ for i, e := range entries {
+ if e.Type == EntryTypeTool {
+ t.Errorf("entry %d: unexpected tool entry in Cursor transcript", i)
+ }
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_DroidUserAndAssistant(t *testing.T) {
+ // Droid uses an envelope: {"type":"message","id":"...","message":{"role":"...","content":[...]}}
+ droidJSONL := strings.Join([]string{
+ `{"type":"session_start","session":{"session_id":"s1"}}`,
+ `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"Help me write a Go function"}]}}`,
+ `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"Sure, here is a function."}]}}`,
+ `{"type":"message","id":"m3","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"main.go","content":"package main"}}]}}`,
+ }, "\n") + "\n"
+
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(droidJSONL)), agent.AgentTypeFactoryAIDroid)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // session_start is skipped; expect: user + assistant text + tool
+ if len(entries) != 3 {
+ t.Fatalf("expected 3 entries, got %d", len(entries))
+ }
+
+ if entries[0].Type != EntryTypeUser {
+ t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
+ }
+ if entries[0].Content != "Help me write a Go function" {
+ t.Errorf("entry 0: unexpected content: %s", entries[0].Content)
+ }
+
+ if entries[1].Type != EntryTypeAssistant {
+ t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
+ }
+ if entries[1].Content != "Sure, here is a function." {
+ t.Errorf("entry 1: unexpected content: %s", entries[1].Content)
+ }
+
+ if entries[2].Type != EntryTypeTool {
+ t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type)
+ }
+ if entries[2].ToolName != "Write" {
+ t.Errorf("entry 2: expected tool name Write, got %s", entries[2].ToolName)
+ }
+ if entries[2].ToolDetail != testMainGoFile {
+ t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_DroidMalformedInput(t *testing.T) {
+ // Completely invalid content should return an error from the Droid parser
+ _, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("not valid jsonl at all{{{")), agent.AgentTypeFactoryAIDroid)
+ // Droid parser is lenient — malformed lines are skipped. With no valid messages,
+ // it returns an empty slice (not an error).
+ if err != nil {
+ t.Fatalf("unexpected error for malformed Droid input: %v", err)
+ }
+}
+
+func TestBuildCondensedTranscriptFromBytes_DroidEmptyTranscript(t *testing.T) {
+ entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("")), agent.AgentTypeFactoryAIDroid)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("expected 0 entries for empty Droid transcript, got %d", len(entries))
+ }
+}
+
+// mustMarshal is a test helper that marshals v to JSON, failing the test on error.
+func mustMarshal(t *testing.T, v interface{}) json.RawMessage {
+ t.Helper()
+ data, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("failed to marshal: %v", err)
+ }
+ return data
+}
+
+func TestResolveModel(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ provider string
+ model string
+ want string
+ }{
+ {
+ name: "claude code with empty model defaults to DefaultModel",
+ provider: string(agent.AgentNameClaudeCode),
+ model: "",
+ want: DefaultModel,
+ },
+ {
+ name: "other provider passes model through unchanged",
+ provider: "codex",
+ model: "gpt-5",
+ want: "gpt-5",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := ResolveModel(types.AgentName(tt.provider), tt.model)
+ if got != tt.want {
+ t.Errorf("ResolveModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/cli/summarize/summarize_test.go b/cli/summarize/summarize_test.go
index 14eb470..618e133 100644
--- a/cli/summarize/summarize_test.go
+++ b/cli/summarize/summarize_test.go
@@ -2,7 +2,6 @@ package summarize
import (
"context"
- "encoding/json"
"errors"
"fmt"
"strings"
@@ -10,7 +9,6 @@ import (
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/claudecode"
- "github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/transcript"
"github.com/GrayCodeAI/trace/redact"
"github.com/stretchr/testify/require"
@@ -804,342 +802,3 @@ func TestBuildCondensedTranscriptFromBytes_Codex_CustomToolCall(t *testing.T) {
t.Fatalf("unexpected final entry: %#v", entries[3])
}
}
-
-func TestBuildCondensedTranscriptFromBytes_Codex_ExecCommandDetail(t *testing.T) {
- t.Parallel()
-
- codexTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
-{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Running command."}]}}
-{"timestamp":"t3","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"ls -la\",\"workdir\":\"/repo\"}"}}
-{"timestamp":"t4","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"total 0"}}
-`)
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted(codexTranscript), agent.AgentTypeCodex)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Find the tool entry
- var toolEntry *Entry
- for i := range entries {
- if entries[i].Type == EntryTypeTool {
- toolEntry = &entries[i]
- break
- }
- }
- require.NotNil(t, toolEntry, "no tool entry found in entries: %#v", entries)
- if toolEntry.ToolName != "exec_command" {
- t.Fatalf("expected exec_command, got %q", toolEntry.ToolName)
- }
- if toolEntry.ToolDetail != "ls -la" {
- t.Fatalf("expected tool detail 'ls -la', got %q", toolEntry.ToolDetail)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T) {
- // OpenCode export JSON format
- ocExportJSON := `{
- "info": {"id": "test-session"},
- "messages": [
- {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Fix the bug in main.go"}]},
- {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": [{"type": "text", "text": "I'll fix the bug."}]}
- ]
- }`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(entries) != 2 {
- t.Fatalf("expected 2 entries, got %d", len(entries))
- }
-
- if entries[0].Type != EntryTypeUser {
- t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
- }
- if entries[0].Content != "Fix the bug in main.go" {
- t.Errorf("entry 0: unexpected content: %s", entries[0].Content)
- }
-
- if entries[1].Type != EntryTypeAssistant {
- t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
- }
- if entries[1].Content != "I'll fix the bug." {
- t.Errorf("entry 1: unexpected content: %s", entries[1].Content)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) {
- // OpenCode export JSON format with tool calls
- ocExportJSON := `{
- "info": {"id": "test-session"},
- "messages": [
- {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "Edit main.go"}]},
- {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": [
- {"type": "text", "text": "Editing now."},
- {"type": "tool", "tool": "edit", "callID": "call-1", "state": {"status": "completed", "input": {"filePath": "main.go"}, "output": "Applied"}},
- {"type": "tool", "tool": "bash", "callID": "call-2", "state": {"status": "completed", "input": {"command": "go test ./..."}, "output": "PASS"}}
- ]}
- ]
- }`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // user + assistant + 2 tool calls
- if len(entries) != 4 {
- t.Fatalf("expected 4 entries, got %d", len(entries))
- }
-
- if entries[2].Type != EntryTypeTool {
- t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type)
- }
- if entries[2].ToolName != "edit" {
- t.Errorf("entry 2: expected tool name edit, got %s", entries[2].ToolName)
- }
- if entries[2].ToolDetail != testMainGoFile {
- t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail)
- }
-
- if entries[3].ToolName != "bash" {
- t.Errorf("entry 3: expected tool name bash, got %s", entries[3].ToolName)
- }
- if entries[3].ToolDetail != "go test ./..." {
- t.Errorf("entry 3: expected tool detail 'go test ./...', got %s", entries[3].ToolDetail)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_OpenCodeSkipsEmptyContent(t *testing.T) {
- // OpenCode export JSON format with empty content messages
- ocExportJSON := `{
- "info": {"id": "test-session"},
- "messages": [
- {"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": []},
- {"info": {"id": "msg-2", "role": "assistant", "time": {"created": 1708300001}}, "parts": []},
- {"info": {"id": "msg-3", "role": "user", "time": {"created": 1708300010}}, "parts": [{"type": "text", "text": "Real prompt"}]}
- ]
- }`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(ocExportJSON)), agent.AgentTypeOpenCode)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(entries) != 1 {
- t.Fatalf("expected 1 entry (empty content skipped), got %d", len(entries))
- }
- if entries[0].Content != "Real prompt" {
- t.Errorf("expected 'Real prompt', got %s", entries[0].Content)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSON(t *testing.T) {
- // Invalid JSON now returns an error (not silently skipped like JSONL)
- _, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("not json")), agent.AgentTypeOpenCode)
- if err == nil {
- t.Fatal("expected error for invalid JSON")
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_CompactTranscriptFallback(t *testing.T) {
- compactJSONL := `{"v":1,"agent":"pi","cli_version":"test","type":"user","ts":"2026-01-01T00:00:00Z","content":[{"text":"Create bye.txt"}]}
-{"v":1,"agent":"pi","cli_version":"test","type":"assistant","ts":"2026-01-01T00:00:01Z","content":[{"type":"tool_use","id":"tc1","name":"Write","input":{"path":"bye.txt"}},{"type":"text","text":"Created bye.txt"}]}
-`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(compactJSONL)), types.AgentType("Pi"))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(entries) != 3 {
- t.Fatalf("expected 3 entries, got %d", len(entries))
- }
- if entries[0].Type != EntryTypeUser || entries[0].Content != "Create bye.txt" {
- t.Fatalf("unexpected first entry: %+v", entries[0])
- }
- if entries[1].Type != EntryTypeTool || entries[1].ToolName != "Write" || entries[1].ToolDetail != "bye.txt" {
- t.Fatalf("unexpected tool entry: %+v", entries[1])
- }
- if entries[2].Type != EntryTypeAssistant || entries[2].Content != "Created bye.txt" {
- t.Fatalf("unexpected assistant entry: %+v", entries[2])
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_CursorRoleBasedJSONL(t *testing.T) {
- // Cursor transcripts use "role" instead of "type" and wrap user text in tags.
- // The transcript parser normalizes role→type, so condensation should work.
- cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"\nhello\n"}]}}
-{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}}
-{"role":"user","message":{"content":[{"type":"text","text":"\nadd one to a file and commit\n"}]}}
-{"role":"assistant","message":{"content":[{"type":"text","text":"Created one.txt with one and committed."}]}}
-`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(cursorJSONL)), agent.AgentTypeCursor)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(entries) == 0 {
- t.Fatal("expected non-empty entries for Cursor transcript, got 0 (role→type normalization may be broken)")
- }
-
- // Should have 4 entries: 2 user + 2 assistant
- if len(entries) != 4 {
- t.Fatalf("expected 4 entries, got %d", len(entries))
- }
-
- if entries[0].Type != EntryTypeUser {
- t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
- }
- if !strings.Contains(entries[0].Content, "hello") {
- t.Errorf("entry 0: expected content containing 'hello', got %q", entries[0].Content)
- }
-
- if entries[1].Type != EntryTypeAssistant {
- t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
- }
- if entries[1].Content != "Hi there!" {
- t.Errorf("entry 1: expected 'Hi there!', got %q", entries[1].Content)
- }
-
- if entries[2].Type != EntryTypeUser {
- t.Errorf("entry 2: expected type %s, got %s", EntryTypeUser, entries[2].Type)
- }
-
- if entries[3].Type != EntryTypeAssistant {
- t.Errorf("entry 3: expected type %s, got %s", EntryTypeAssistant, entries[3].Type)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_CursorNoToolUseBlocks(t *testing.T) {
- // Cursor transcripts have no tool_use blocks — only text content.
- // This verifies we get entries (not an empty result) even without tool calls.
- cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"write a poem"}]}}
-{"role":"assistant","message":{"content":[{"type":"text","text":"Here is a poem about code."}]}}
-`
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(cursorJSONL)), agent.AgentTypeCursor)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(entries) != 2 {
- t.Fatalf("expected 2 entries, got %d", len(entries))
- }
-
- // No tool entries should appear
- for i, e := range entries {
- if e.Type == EntryTypeTool {
- t.Errorf("entry %d: unexpected tool entry in Cursor transcript", i)
- }
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_DroidUserAndAssistant(t *testing.T) {
- // Droid uses an envelope: {"type":"message","id":"...","message":{"role":"...","content":[...]}}
- droidJSONL := strings.Join([]string{
- `{"type":"session_start","session":{"session_id":"s1"}}`,
- `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"Help me write a Go function"}]}}`,
- `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"Sure, here is a function."}]}}`,
- `{"type":"message","id":"m3","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"main.go","content":"package main"}}]}}`,
- }, "\n") + "\n"
-
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(droidJSONL)), agent.AgentTypeFactoryAIDroid)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // session_start is skipped; expect: user + assistant text + tool
- if len(entries) != 3 {
- t.Fatalf("expected 3 entries, got %d", len(entries))
- }
-
- if entries[0].Type != EntryTypeUser {
- t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type)
- }
- if entries[0].Content != "Help me write a Go function" {
- t.Errorf("entry 0: unexpected content: %s", entries[0].Content)
- }
-
- if entries[1].Type != EntryTypeAssistant {
- t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type)
- }
- if entries[1].Content != "Sure, here is a function." {
- t.Errorf("entry 1: unexpected content: %s", entries[1].Content)
- }
-
- if entries[2].Type != EntryTypeTool {
- t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type)
- }
- if entries[2].ToolName != "Write" {
- t.Errorf("entry 2: expected tool name Write, got %s", entries[2].ToolName)
- }
- if entries[2].ToolDetail != testMainGoFile {
- t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_DroidMalformedInput(t *testing.T) {
- // Completely invalid content should return an error from the Droid parser
- _, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("not valid jsonl at all{{{")), agent.AgentTypeFactoryAIDroid)
- // Droid parser is lenient — malformed lines are skipped. With no valid messages,
- // it returns an empty slice (not an error).
- if err != nil {
- t.Fatalf("unexpected error for malformed Droid input: %v", err)
- }
-}
-
-func TestBuildCondensedTranscriptFromBytes_DroidEmptyTranscript(t *testing.T) {
- entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte("")), agent.AgentTypeFactoryAIDroid)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(entries) != 0 {
- t.Errorf("expected 0 entries for empty Droid transcript, got %d", len(entries))
- }
-}
-
-// mustMarshal is a test helper that marshals v to JSON, failing the test on error.
-func mustMarshal(t *testing.T, v interface{}) json.RawMessage {
- t.Helper()
- data, err := json.Marshal(v)
- if err != nil {
- t.Fatalf("failed to marshal: %v", err)
- }
- return data
-}
-
-func TestResolveModel(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- provider string
- model string
- want string
- }{
- {
- name: "claude code with empty model defaults to DefaultModel",
- provider: string(agent.AgentNameClaudeCode),
- model: "",
- want: DefaultModel,
- },
- {
- name: "other provider passes model through unchanged",
- provider: "codex",
- model: "gpt-5",
- want: "gpt-5",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := ResolveModel(types.AgentName(tt.provider), tt.model)
- if got != tt.want {
- t.Errorf("ResolveModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
- }
- })
- }
-}
diff --git a/cli/trail_cmd.go b/cli/trail_cmd.go
index fbb3237..7b53d22 100644
--- a/cli/trail_cmd.go
+++ b/cli/trail_cmd.go
@@ -6,13 +6,10 @@ import (
"errors"
"fmt"
"io"
- "net/http"
"os"
- "os/exec"
"sort"
"strings"
"text/tabwriter"
- "time"
"github.com/GrayCodeAI/trace/cli/api"
"github.com/GrayCodeAI/trace/cli/gitremote"
@@ -20,8 +17,6 @@ import (
"github.com/GrayCodeAI/trace/cli/trail"
"charm.land/huh/v2"
- "github.com/go-git/go-git/v6"
- "github.com/go-git/go-git/v6/plumbing"
"github.com/spf13/cobra"
)
@@ -823,179 +818,3 @@ const defaultBaseBranch = "main"
// constant so goconst stays quiet across the several call sites in the cli
// package.
const masterBaseBranch = "master"
-
-func formatValidStatuses() string {
- statuses := trail.ValidStatuses()
- names := make([]string, len(statuses))
- for i, s := range statuses {
- names[i] = string(s)
- }
- return strings.Join(names, ", ")
-}
-
-// runTrailCreateInteractive runs the interactive form for trail creation.
-// Prompts for title, body, branch (derived from title), and status.
-func runTrailCreateInteractive(title, body, branch, statusStr *string) error {
- // Step 1: Title and body
- form := NewAccessibleForm(
- huh.NewGroup(
- huh.NewInput().
- Title("Trail title").
- Placeholder("What are you working on?").
- Value(title),
- huh.NewText().
- Title("Body (optional)").
- Value(body),
- ),
- )
- if err := form.Run(); err != nil {
- return fmt.Errorf("form cancelled: %w", err)
- }
- *title = strings.TrimSpace(*title)
- if *title == "" {
- return errors.New("trail title is required")
- }
-
- // Step 2: Branch (derived from title) and status
- suggested := slugifyTitle(*title)
- *branch = suggested
-
- // Build status options, excluding done/closed
- var statusOptions []huh.Option[string]
- for _, s := range trail.ValidStatuses() {
- if s == trail.StatusMerged || s == trail.StatusClosed {
- continue
- }
- statusOptions = append(statusOptions, huh.NewOption(string(s), string(s)))
- }
- if *statusStr == "" {
- *statusStr = string(trail.StatusDraft)
- }
-
- form = NewAccessibleForm(
- huh.NewGroup(
- huh.NewInput().
- Title("Branch name").
- Placeholder(suggested).
- Value(branch),
- huh.NewSelect[string]().
- Title("Status").
- Options(statusOptions...).
- Value(statusStr),
- ),
- )
- if err := form.Run(); err != nil {
- return fmt.Errorf("form cancelled: %w", err)
- }
- *branch = strings.TrimSpace(*branch)
- if *branch == "" {
- *branch = suggested
- }
- return nil
-}
-
-// findTrailByBranch looks up a trail by branch name via the list API.
-func findTrailByBranch(ctx context.Context, client *api.Client, host, owner, repo, branch string) (*api.TrailResource, error) {
- return findTrail(ctx, client, host, owner, repo, func(t api.TrailResource) bool {
- return t.Branch == branch
- })
-}
-
-func findTrail(ctx context.Context, client *api.Client, host, owner, repo string, match func(api.TrailResource) bool) (*api.TrailResource, error) {
- resp, err := client.Get(ctx, trailsBasePath(host, owner, repo))
- if err != nil {
- return nil, fmt.Errorf("list trails: %w", err)
- }
- defer resp.Body.Close()
- if err := checkTrailResponse(resp); err != nil {
- return nil, err
- }
-
- var listResp api.TrailListResponse
- if err := api.DecodeJSON(resp, &listResp); err != nil {
- return nil, fmt.Errorf("decode trail list: %w", err)
- }
-
- for i := range listResp.Trails {
- if match(listResp.Trails[i]) {
- return &listResp.Trails[i], nil
- }
- }
- return nil, nil //nolint:nilnil // nil, nil means "not found" — callers check both
-}
-
-// apiHostAlias maps git host domains to short aliases used by the trails API.
-var apiHostAlias = map[string]string{
- "github.com": "gh",
-}
-
-// trailsBasePath returns the API path prefix for trails endpoints (e.g., "/api/v1/trails/gh/org/repo").
-func trailsBasePath(host, owner, repo string) string {
- if alias, ok := apiHostAlias[host]; ok {
- host = alias
- }
- return fmt.Sprintf("/api/v1/trails/%s/%s/%s", host, owner, repo)
-}
-
-// checkTrailResponse checks the API response and returns user-friendly errors.
-// For auth failures, it appends a hint to re-authenticate while preserving the server's error message.
-func checkTrailResponse(resp *http.Response) error {
- if err := api.CheckResponse(resp); err != nil {
- if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
- return fmt.Errorf("%w — run 'trace login' to re-authenticate", err)
- }
- return fmt.Errorf("trail API: %w", err)
- }
- return nil
-}
-
-// slugifyTitle converts a title string into a branch-friendly slug.
-// Example: "Add user authentication" -> "add-user-authentication"
-func slugifyTitle(title string) string {
- s := strings.ToLower(strings.TrimSpace(title))
- // Replace spaces and underscores with hyphens
- s = strings.NewReplacer(" ", "-", "_", "-").Replace(s)
- // Remove anything that's not alphanumeric, hyphen, or slash
- var b strings.Builder
- prevHyphen := false
- for _, r := range s {
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '/' {
- b.WriteRune(r)
- prevHyphen = false
- } else if r == '-' && !prevHyphen {
- b.WriteRune('-')
- prevHyphen = true
- }
- }
- return strings.Trim(b.String(), "-")
-}
-
-// branchNeedsCreation checks if a branch exists locally.
-func branchNeedsCreation(repo *git.Repository, branchName string) bool {
- _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
- return err != nil
-}
-
-// createBranch creates a new local branch pointing at HEAD without checking it out.
-func createBranch(repo *git.Repository, branchName string) error {
- head, err := repo.Head()
- if err != nil {
- return fmt.Errorf("failed to get HEAD: %w", err)
- }
- ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), head.Hash())
- if err := repo.Storer.SetReference(ref); err != nil {
- return fmt.Errorf("failed to create branch ref: %w", err)
- }
- return nil
-}
-
-// pushBranchToOrigin pushes a branch to the origin remote.
-func pushBranchToOrigin(branchName string) error {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
- defer cancel()
- cmd := exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", "origin", branchName)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
- }
- return nil
-}
diff --git a/cli/trail_cmd_2.go b/cli/trail_cmd_2.go
new file mode 100644
index 0000000..02b5892
--- /dev/null
+++ b/cli/trail_cmd_2.go
@@ -0,0 +1,194 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/GrayCodeAI/trace/cli/api"
+ "github.com/GrayCodeAI/trace/cli/trail"
+
+ "charm.land/huh/v2"
+ "github.com/go-git/go-git/v6"
+ "github.com/go-git/go-git/v6/plumbing"
+)
+
+func formatValidStatuses() string {
+ statuses := trail.ValidStatuses()
+ names := make([]string, len(statuses))
+ for i, s := range statuses {
+ names[i] = string(s)
+ }
+ return strings.Join(names, ", ")
+}
+
+// runTrailCreateInteractive runs the interactive form for trail creation.
+// Prompts for title, body, branch (derived from title), and status.
+func runTrailCreateInteractive(title, body, branch, statusStr *string) error {
+ // Step 1: Title and body
+ form := NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Trail title").
+ Placeholder("What are you working on?").
+ Value(title),
+ huh.NewText().
+ Title("Body (optional)").
+ Value(body),
+ ),
+ )
+ if err := form.Run(); err != nil {
+ return fmt.Errorf("form cancelled: %w", err)
+ }
+ *title = strings.TrimSpace(*title)
+ if *title == "" {
+ return errors.New("trail title is required")
+ }
+
+ // Step 2: Branch (derived from title) and status
+ suggested := slugifyTitle(*title)
+ *branch = suggested
+
+ // Build status options, excluding done/closed
+ var statusOptions []huh.Option[string]
+ for _, s := range trail.ValidStatuses() {
+ if s == trail.StatusMerged || s == trail.StatusClosed {
+ continue
+ }
+ statusOptions = append(statusOptions, huh.NewOption(string(s), string(s)))
+ }
+ if *statusStr == "" {
+ *statusStr = string(trail.StatusDraft)
+ }
+
+ form = NewAccessibleForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Branch name").
+ Placeholder(suggested).
+ Value(branch),
+ huh.NewSelect[string]().
+ Title("Status").
+ Options(statusOptions...).
+ Value(statusStr),
+ ),
+ )
+ if err := form.Run(); err != nil {
+ return fmt.Errorf("form cancelled: %w", err)
+ }
+ *branch = strings.TrimSpace(*branch)
+ if *branch == "" {
+ *branch = suggested
+ }
+ return nil
+}
+
+// findTrailByBranch looks up a trail by branch name via the list API.
+func findTrailByBranch(ctx context.Context, client *api.Client, host, owner, repo, branch string) (*api.TrailResource, error) {
+ return findTrail(ctx, client, host, owner, repo, func(t api.TrailResource) bool {
+ return t.Branch == branch
+ })
+}
+
+func findTrail(ctx context.Context, client *api.Client, host, owner, repo string, match func(api.TrailResource) bool) (*api.TrailResource, error) {
+ resp, err := client.Get(ctx, trailsBasePath(host, owner, repo))
+ if err != nil {
+ return nil, fmt.Errorf("list trails: %w", err)
+ }
+ defer resp.Body.Close()
+ if err := checkTrailResponse(resp); err != nil {
+ return nil, err
+ }
+
+ var listResp api.TrailListResponse
+ if err := api.DecodeJSON(resp, &listResp); err != nil {
+ return nil, fmt.Errorf("decode trail list: %w", err)
+ }
+
+ for i := range listResp.Trails {
+ if match(listResp.Trails[i]) {
+ return &listResp.Trails[i], nil
+ }
+ }
+ return nil, nil //nolint:nilnil // nil, nil means "not found" — callers check both
+}
+
+// apiHostAlias maps git host domains to short aliases used by the trails API.
+var apiHostAlias = map[string]string{
+ "github.com": "gh",
+}
+
+// trailsBasePath returns the API path prefix for trails endpoints (e.g., "/api/v1/trails/gh/org/repo").
+func trailsBasePath(host, owner, repo string) string {
+ if alias, ok := apiHostAlias[host]; ok {
+ host = alias
+ }
+ return fmt.Sprintf("/api/v1/trails/%s/%s/%s", host, owner, repo)
+}
+
+// checkTrailResponse checks the API response and returns user-friendly errors.
+// For auth failures, it appends a hint to re-authenticate while preserving the server's error message.
+func checkTrailResponse(resp *http.Response) error {
+ if err := api.CheckResponse(resp); err != nil {
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ return fmt.Errorf("%w — run 'trace login' to re-authenticate", err)
+ }
+ return fmt.Errorf("trail API: %w", err)
+ }
+ return nil
+}
+
+// slugifyTitle converts a title string into a branch-friendly slug.
+// Example: "Add user authentication" -> "add-user-authentication"
+func slugifyTitle(title string) string {
+ s := strings.ToLower(strings.TrimSpace(title))
+ // Replace spaces and underscores with hyphens
+ s = strings.NewReplacer(" ", "-", "_", "-").Replace(s)
+ // Remove anything that's not alphanumeric, hyphen, or slash
+ var b strings.Builder
+ prevHyphen := false
+ for _, r := range s {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '/' {
+ b.WriteRune(r)
+ prevHyphen = false
+ } else if r == '-' && !prevHyphen {
+ b.WriteRune('-')
+ prevHyphen = true
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// branchNeedsCreation checks if a branch exists locally.
+func branchNeedsCreation(repo *git.Repository, branchName string) bool {
+ _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
+ return err != nil
+}
+
+// createBranch creates a new local branch pointing at HEAD without checking it out.
+func createBranch(repo *git.Repository, branchName string) error {
+ head, err := repo.Head()
+ if err != nil {
+ return fmt.Errorf("failed to get HEAD: %w", err)
+ }
+ ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), head.Hash())
+ if err := repo.Storer.SetReference(ref); err != nil {
+ return fmt.Errorf("failed to create branch ref: %w", err)
+ }
+ return nil
+}
+
+// pushBranchToOrigin pushes a branch to the origin remote.
+func pushBranchToOrigin(branchName string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", "origin", branchName)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
+ }
+ return nil
+}
diff --git a/redact/redact_2_test.go b/redact/redact_2_test.go
new file mode 100644
index 0000000..a64d532
--- /dev/null
+++ b/redact/redact_2_test.go
@@ -0,0 +1,531 @@
+package redact
+
+import (
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestJSONLContent_StructuredCredentialFieldsRedacted(t *testing.T) {
+ t.Parallel()
+ input := `{"type":"assistant","env":{"DB_PASSWORD":"correct-horse-db","REDIS_PASSWORD":"${REDIS_PASSWORD}","note":"correct-horse-db"},"db":{"password":"correct-horse-db","host":"db.example.com","user":"svc"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, leaked := range []string{`"DB_PASSWORD":"correct-horse-db"`, `"password":"correct-horse-db"`} {
+ if strings.Contains(result, leaked) {
+ t.Fatalf("expected structured credential field %q to be redacted, got: %s", leaked, result)
+ }
+ }
+ for _, preserved := range []string{
+ `"DB_PASSWORD":"` + wantRedacted + `"`,
+ `"REDIS_PASSWORD":"${REDIS_PASSWORD}"`,
+ `"password":"` + wantRedacted + `"`,
+ `"host":"db.example.com"`,
+ `"user":"svc"`,
+ `"note":"correct-horse-db"`,
+ testSessionID,
+ } {
+ if !strings.Contains(result, preserved) {
+ t.Fatalf("expected %q to be preserved, got: %s", preserved, result)
+ }
+ }
+}
+
+func TestJSONLContent_NormalizedCredentialKeysRedacted(t *testing.T) {
+ t.Parallel()
+ input := `{"type":"assistant","env":{"DB Password":"correct-horse-db","note":"correct-horse-db"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, preserved := range []string{
+ `"DB Password":"` + wantRedacted + `"`,
+ `"note":"correct-horse-db"`,
+ testSessionID,
+ } {
+ if !strings.Contains(result, preserved) {
+ t.Fatalf("expected %q to be preserved, got: %s", preserved, result)
+ }
+ }
+ if strings.Contains(result, `"DB Password":"correct-horse-db"`) {
+ t.Fatalf("expected normalized credential key to be redacted, got: %s", result)
+ }
+}
+
+func TestJSONLContent_DottedCredentialKeysRedacted(t *testing.T) {
+ t.Parallel()
+ input := `{"config":{"db.password":"correct-horse-db","mysql.root.password":"correct-horse-mysql","note":"correct-horse-db"}}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, redacted := range []string{
+ `"db.password":"` + wantRedacted + `"`,
+ `"mysql.root.password":"` + wantRedacted + `"`,
+ } {
+ if !strings.Contains(result, redacted) {
+ t.Fatalf("expected %q in output, got: %s", redacted, result)
+ }
+ }
+ if !strings.Contains(result, `"note":"correct-horse-db"`) {
+ t.Fatalf("expected unrelated note field to be preserved, got: %s", result)
+ }
+}
+
+func TestJSONLContent_RootPasswordJSONKeysRedacted(t *testing.T) {
+ t.Parallel()
+ input := `{"env":{"MYSQL_ROOT_PASSWORD":"correct-horse-mysql","MONGO_INITDB_ROOT_PASSWORD":"correct-horse-mongo","MSSQL_SA_PASSWORD":"correct-horse-mssql"}}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, redacted := range []string{
+ `"MYSQL_ROOT_PASSWORD":"` + wantRedacted + `"`,
+ `"MONGO_INITDB_ROOT_PASSWORD":"` + wantRedacted + `"`,
+ `"MSSQL_SA_PASSWORD":"` + wantRedacted + `"`,
+ } {
+ if !strings.Contains(result, redacted) {
+ t.Fatalf("expected %q in output, got: %s", redacted, result)
+ }
+ }
+ for _, leaked := range []string{"correct-horse-mysql", "correct-horse-mongo", "correct-horse-mssql"} {
+ if strings.Contains(result, leaked) {
+ t.Fatalf("expected %q to be redacted, got: %s", leaked, result)
+ }
+ }
+}
+
+func TestShouldSkipJSONLObject(t *testing.T) {
+ tests := []struct {
+ name string
+ obj map[string]any
+ want bool
+ }{
+ {
+ name: "image type is skipped",
+ obj: map[string]any{testFieldType: "image", "data": "base64data"},
+ want: true,
+ },
+ {
+ name: "text type is not skipped",
+ obj: map[string]any{testFieldType: testFieldText, testFieldContent: "hello"},
+ want: false,
+ },
+ {
+ name: "no type field is not skipped",
+ obj: map[string]any{testFieldContent: "hello"},
+ want: false,
+ },
+ {
+ name: "non-string type is not skipped",
+ obj: map[string]any{testFieldType: 42},
+ want: false,
+ },
+ {
+ name: "image_url type is skipped",
+ obj: map[string]any{testFieldType: "image_url"},
+ want: true,
+ },
+ {
+ name: "base64 type is skipped",
+ obj: map[string]any{testFieldType: "base64"},
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := shouldSkipJSONLObject(tt.obj)
+ if got != tt.want {
+ t.Errorf("shouldSkipJSONLObject(%v) = %v, want %v", tt.obj, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestShouldSkipJSONLObject_RedactionBehavior(t *testing.T) {
+ // Verify that secrets inside image objects are NOT redacted.
+ obj := map[string]any{
+ testFieldType: "image",
+ "data": highEntropySecret,
+ }
+ repls := collectJSONLReplacements(obj)
+
+ // expect no replacements, it's an image which is skipped.
+ var wantRepls []jsonReplacement
+ if !slices.Equal(repls, wantRepls) {
+ t.Errorf("got %q, want %q", repls, wantRepls)
+ }
+
+ // Verify that secrets inside non-image objects ARE redacted.
+ obj2 := map[string]any{
+ testFieldType: "text",
+ testFieldContent: highEntropySecret,
+ }
+ repls2 := collectJSONLReplacements(obj2)
+ wantRepls2 := []jsonReplacement{{key: testFieldContent, original: highEntropySecret, redacted: wantRedacted}}
+ if !slices.Equal(repls2, wantRepls2) {
+ t.Errorf("got %q, want %q", repls2, wantRepls2)
+ }
+}
+
+func TestString_FilePaths(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "temp directory path preserves filenames",
+ input: testPathTmpE2E,
+ want: testPathTmpE2E,
+ },
+ {
+ name: "macOS private var folders path",
+ input: testPathPrivateVar,
+ want: testPathPrivateVar,
+ },
+ {
+ name: "simple Go file path",
+ input: "Reading file: /tmp/test/model.go",
+ want: "Reading file: /tmp/test/model.go",
+ },
+ {
+ name: "user home directory path",
+ input: testPathUserClaude,
+ want: testPathUserClaude,
+ },
+ {
+ name: "multiple paths separated by newlines",
+ input: testPathMultilineFiles,
+ want: testPathMultilineFiles,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := String(tt.input)
+ if got != tt.want {
+ t.Errorf("String(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestString_JSONEscapeSequences(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "newline escape not corrupted",
+ input: testJSONEscapeNewline,
+ want: testJSONEscapeNewline,
+ },
+ {
+ name: "tab escape not corrupted",
+ input: testJSONEscapeTab,
+ want: testJSONEscapeTab,
+ },
+ {
+ name: "backslash escape not corrupted",
+ input: testJSONEscapeBackslash,
+ want: testJSONEscapeBackslash,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := String(tt.input)
+ if got != tt.want {
+ t.Errorf("String(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestString_RealSecretsStillCaught(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ input string
+ }{
+ {
+ name: "high entropy API key",
+ input: "api_key=" + highEntropySecret,
+ },
+ {
+ name: "AWS access key (pattern-based)",
+ input: "key=AKIAYRWQG5EJLPZLBYNP",
+ },
+ {
+ name: "GitHub personal access token",
+ input: "token=ghp_1234567890abcdefghijklmnopqrstuvwxyzAB",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := String(tt.input)
+ if !strings.Contains(got, wantRedacted) {
+ t.Errorf("String(%q) = %q, expected REDACTED somewhere", tt.input, got)
+ }
+ })
+ }
+}
+
+func TestJSONLContent_PathFieldsPreserved(t *testing.T) {
+ t.Parallel()
+ // Simulates a real agent log line with path fields that should NOT be redacted
+ input := `{"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0","file_path":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test/controller.go","cwd":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test","root":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test","directory":"/tmp/TestE2E_ExistingFiles","content":"normal text here"}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Structural fields should be preserved
+ mustContain := []string{
+ testSessionID, // session_id (skipped by *id rule)
+ "/private/var/folders", // file_path (skipped by path rule)
+ "controller.go", // filename in file_path
+ "/tmp/TestE2E_ExistingFiles", // directory (skipped by path rule)
+ }
+ for _, s := range mustContain {
+ if !strings.Contains(result, s) {
+ t.Errorf("expected %q to be preserved, but result is: %s", s, result)
+ }
+ }
+
+ // No false positives
+ if strings.Contains(result, wantRedacted) {
+ t.Errorf("expected no redactions in structural fields, got: %s", result)
+ }
+}
+
+func TestJSONLContent_PrettyPrintedJSON_IDsPreserved(t *testing.T) {
+ t.Parallel()
+ // Simulates OpenCode's pretty-printed JSON export format.
+ // High-entropy IDs (like msg_cb99a444f001Ftd3kTVmr8XQHZ with entropy > 4.5)
+ // must be preserved. Before the fix, line-by-line processing couldn't parse
+ // individual lines of pretty-printed JSON and fell back to entropy-based
+ // redaction, corrupting these IDs.
+ input := `{
+ "info": {
+ "id": "ses_309461a8bffeQfY7CYDOUHX6VP",
+ "slug": "misty-river",
+ "directory": "/tmp/test-repo"
+ },
+ "messages": [
+ {
+ "info": {
+ "id": "msg_cb99a444f001Ftd3kTVmr8XQHZ",
+ "sessionID": "ses_309461a8bffeQfY7CYDOUHX6VP",
+ "role": "user"
+ },
+ "parts": [
+ {
+ "id": "prt_cb99a443b001GE99vjBG60vHbF",
+ "type": "text",
+ "text": "hello world"
+ }
+ ]
+ },
+ {
+ "info": {
+ "id": "msg_cb99a444f001Ftd3kTVmr8XQHZ",
+ "sessionID": "ses_309461a8bffeQfY7CYDOUHX6VP",
+ "role": "assistant"
+ },
+ "parts": [
+ {
+ "id": "prt_cb99a6f2e0012koCcOJBSwRBwR",
+ "type": "text",
+ "text": "hello back"
+ },
+ {
+ "id": "prt_cb99a6f2f001e98CKuwDKU3oWr",
+ "type": "tool",
+ "tool": "write",
+ "callID": "call_abc123",
+ "state": {
+ "status": "completed",
+ "input": {"filePath": "/tmp/test/hello.md"},
+ "output": "wrote file",
+ "metadata": {"files": [{"filePath": "/tmp/test/hello.md", "relativePath": "hello.md"}]}
+ }
+ }
+ ]
+ }
+ ]
+}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Verify the entropy threshold: msg_cb99a444f001Ftd3kTVmr8XQHZ has entropy > 4.5
+ // and would be redacted by String() if processed line-by-line.
+ entropy := shannonEntropy("msg_cb99a444f001Ftd3kTVmr8XQHZ")
+ if entropy <= entropyThreshold {
+ t.Fatalf("test assumption broken: msg ID entropy %.2f should be > %.1f", entropy, entropyThreshold)
+ }
+
+ // All IDs must be preserved (they're in "id"/"sessionID" fields which are skipped).
+ mustContain := []string{
+ "ses_309461a8bffeQfY7CYDOUHX6VP",
+ "msg_cb99a444f001Ftd3kTVmr8XQHZ",
+ "prt_cb99a443b001GE99vjBG60vHbF",
+ "prt_cb99a6f2e0012koCcOJBSwRBwR",
+ "prt_cb99a6f2f001e98CKuwDKU3oWr",
+ }
+ for _, s := range mustContain {
+ if !strings.Contains(result, s) {
+ t.Errorf("expected ID %q to be preserved, but it was corrupted in result", s)
+ }
+ }
+
+ // No false positives on structural data.
+ if strings.Contains(result, wantRedacted) {
+ t.Errorf("expected no redactions in OpenCode export, got redacted content")
+ }
+}
+
+func TestJSONLContent_PrettyPrintedJSON_SecretsStillCaught(t *testing.T) {
+ t.Parallel()
+ // Even in pretty-printed JSON mode, actual secrets in content fields should
+ // still be redacted.
+ input := `{
+ "info": {
+ "id": "ses_test123"
+ },
+ "messages": [
+ {
+ "info": {
+ "id": "msg_test456",
+ "role": "assistant"
+ },
+ "parts": [
+ {
+ "id": "prt_test789",
+ "type": "text",
+ "text": "your api key is ` + highEntropySecret + `"
+ }
+ ]
+ }
+ ]
+}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Secret in text content should be redacted.
+ if strings.Contains(result, highEntropySecret) {
+ t.Error("secret in text field was not redacted")
+ }
+ if !strings.Contains(result, wantRedacted) {
+ t.Error("expected REDACTED in output")
+ }
+
+ // IDs should still be preserved.
+ for _, id := range []string{"ses_test123", "msg_test456", "prt_test789"} {
+ if !strings.Contains(result, id) {
+ t.Errorf("ID %q should be preserved", id)
+ }
+ }
+}
+
+func TestJSONLContent_SecretsInContentStillCaught(t *testing.T) {
+ t.Parallel()
+ // Path fields should be preserved, but secrets in content should be caught
+ input := `{"file_path":"/tmp/test.go","content":"api_key=` + highEntropySecret + `"}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // file_path should be preserved
+ if !strings.Contains(result, "/tmp/test.go") {
+ t.Error("file_path was incorrectly modified")
+ }
+
+ // Secret in content should be redacted
+ if strings.Contains(result, highEntropySecret) {
+ t.Error("secret in content field was not redacted")
+ }
+ if !strings.Contains(result, wantRedacted) {
+ t.Error("expected REDACTED in output")
+ }
+}
+
+// Pins a known gap: shell shorthand `--password=...` is not redacted because
+// no detector matches `--password=` (no DB-prefix, no DSN structure, no URI).
+func TestString_MysqlShellShorthandIsNotRedacted(t *testing.T) {
+ t.Parallel()
+ assertStringRedactionCases(t, []stringRedactionCase{
+ {
+ name: "mysql cli flag",
+ input: "mysql -u svc --password=hunter2 -h db.example.com app",
+ want: "mysql -u svc --password=hunter2 -h db.example.com app",
+ },
+ {
+ name: "psql cli flag",
+ input: "psql --password=hunter2 -U svc -h db.example.com app",
+ want: "psql --password=hunter2 -U svc -h db.example.com app",
+ },
+ })
+}
+
+// Pins f(f(x)) == f(x): once-redacted output must not match any detector on
+// a second pass.
+func TestString_RedactionIsIdempotent(t *testing.T) {
+ t.Parallel()
+ inputs := []string{
+ "DATABASE_URL=postgres://svc:hunter2@db.example.com/app",
+ "DB_PASSWORD=hunter2",
+ `conn=Server=db.example.com;User ID=svc;Password="se;cret;here";Encrypt=true`,
+ "jdbc:postgresql://db.example.com:5432/app?user=svc&password=hunter2",
+ "my key is " + highEntropySecret + " ok",
+ }
+ for _, input := range inputs {
+ t.Run(input, func(t *testing.T) {
+ t.Parallel()
+ once := String(input)
+ twice := String(once)
+ if once != twice {
+ t.Errorf("not idempotent for %q:\n once: %q\n twice: %q", input, once, twice)
+ }
+ })
+ }
+}
+
+// Pins keyed-JSON replacement as (key, value) rather than (path, value): a
+// shared value under the same key name redacts in every context, not just
+// the credential one. Conservative on purpose — flag if changed.
+func TestJSONLContent_CrossContextValueCollision(t *testing.T) {
+ t.Parallel()
+ input := `{"db":{"host":"db.example.com","user":"svc","password":"shared-secret"},"misc":{"password":"shared-secret"}}`
+
+ result, err := JSONLContent(input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if strings.Contains(result, "shared-secret") {
+ t.Errorf("expected shared-secret to be redacted in both contexts, got: %s", result)
+ }
+ if strings.Count(result, `"password":"`+wantRedacted+`"`) != 2 {
+ t.Errorf("expected both password fields redacted, got: %s", result)
+ }
+}
diff --git a/redact/redact_test.go b/redact/redact_test.go
index b2d2b89..d4d08ea 100644
--- a/redact/redact_test.go
+++ b/redact/redact_test.go
@@ -792,527 +792,3 @@ func TestJSONLContent_DatabaseCredentialRedaction(t *testing.T) {
}
}
}
-
-func TestJSONLContent_StructuredCredentialFieldsRedacted(t *testing.T) {
- t.Parallel()
- input := `{"type":"assistant","env":{"DB_PASSWORD":"correct-horse-db","REDIS_PASSWORD":"${REDIS_PASSWORD}","note":"correct-horse-db"},"db":{"password":"correct-horse-db","host":"db.example.com","user":"svc"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- for _, leaked := range []string{`"DB_PASSWORD":"correct-horse-db"`, `"password":"correct-horse-db"`} {
- if strings.Contains(result, leaked) {
- t.Fatalf("expected structured credential field %q to be redacted, got: %s", leaked, result)
- }
- }
- for _, preserved := range []string{
- `"DB_PASSWORD":"` + wantRedacted + `"`,
- `"REDIS_PASSWORD":"${REDIS_PASSWORD}"`,
- `"password":"` + wantRedacted + `"`,
- `"host":"db.example.com"`,
- `"user":"svc"`,
- `"note":"correct-horse-db"`,
- testSessionID,
- } {
- if !strings.Contains(result, preserved) {
- t.Fatalf("expected %q to be preserved, got: %s", preserved, result)
- }
- }
-}
-
-func TestJSONLContent_NormalizedCredentialKeysRedacted(t *testing.T) {
- t.Parallel()
- input := `{"type":"assistant","env":{"DB Password":"correct-horse-db","note":"correct-horse-db"},"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0"}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- for _, preserved := range []string{
- `"DB Password":"` + wantRedacted + `"`,
- `"note":"correct-horse-db"`,
- testSessionID,
- } {
- if !strings.Contains(result, preserved) {
- t.Fatalf("expected %q to be preserved, got: %s", preserved, result)
- }
- }
- if strings.Contains(result, `"DB Password":"correct-horse-db"`) {
- t.Fatalf("expected normalized credential key to be redacted, got: %s", result)
- }
-}
-
-func TestJSONLContent_DottedCredentialKeysRedacted(t *testing.T) {
- t.Parallel()
- input := `{"config":{"db.password":"correct-horse-db","mysql.root.password":"correct-horse-mysql","note":"correct-horse-db"}}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- for _, redacted := range []string{
- `"db.password":"` + wantRedacted + `"`,
- `"mysql.root.password":"` + wantRedacted + `"`,
- } {
- if !strings.Contains(result, redacted) {
- t.Fatalf("expected %q in output, got: %s", redacted, result)
- }
- }
- if !strings.Contains(result, `"note":"correct-horse-db"`) {
- t.Fatalf("expected unrelated note field to be preserved, got: %s", result)
- }
-}
-
-func TestJSONLContent_RootPasswordJSONKeysRedacted(t *testing.T) {
- t.Parallel()
- input := `{"env":{"MYSQL_ROOT_PASSWORD":"correct-horse-mysql","MONGO_INITDB_ROOT_PASSWORD":"correct-horse-mongo","MSSQL_SA_PASSWORD":"correct-horse-mssql"}}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- for _, redacted := range []string{
- `"MYSQL_ROOT_PASSWORD":"` + wantRedacted + `"`,
- `"MONGO_INITDB_ROOT_PASSWORD":"` + wantRedacted + `"`,
- `"MSSQL_SA_PASSWORD":"` + wantRedacted + `"`,
- } {
- if !strings.Contains(result, redacted) {
- t.Fatalf("expected %q in output, got: %s", redacted, result)
- }
- }
- for _, leaked := range []string{"correct-horse-mysql", "correct-horse-mongo", "correct-horse-mssql"} {
- if strings.Contains(result, leaked) {
- t.Fatalf("expected %q to be redacted, got: %s", leaked, result)
- }
- }
-}
-
-func TestShouldSkipJSONLObject(t *testing.T) {
- tests := []struct {
- name string
- obj map[string]any
- want bool
- }{
- {
- name: "image type is skipped",
- obj: map[string]any{testFieldType: "image", "data": "base64data"},
- want: true,
- },
- {
- name: "text type is not skipped",
- obj: map[string]any{testFieldType: testFieldText, testFieldContent: "hello"},
- want: false,
- },
- {
- name: "no type field is not skipped",
- obj: map[string]any{testFieldContent: "hello"},
- want: false,
- },
- {
- name: "non-string type is not skipped",
- obj: map[string]any{testFieldType: 42},
- want: false,
- },
- {
- name: "image_url type is skipped",
- obj: map[string]any{testFieldType: "image_url"},
- want: true,
- },
- {
- name: "base64 type is skipped",
- obj: map[string]any{testFieldType: "base64"},
- want: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := shouldSkipJSONLObject(tt.obj)
- if got != tt.want {
- t.Errorf("shouldSkipJSONLObject(%v) = %v, want %v", tt.obj, got, tt.want)
- }
- })
- }
-}
-
-func TestShouldSkipJSONLObject_RedactionBehavior(t *testing.T) {
- // Verify that secrets inside image objects are NOT redacted.
- obj := map[string]any{
- testFieldType: "image",
- "data": highEntropySecret,
- }
- repls := collectJSONLReplacements(obj)
-
- // expect no replacements, it's an image which is skipped.
- var wantRepls []jsonReplacement
- if !slices.Equal(repls, wantRepls) {
- t.Errorf("got %q, want %q", repls, wantRepls)
- }
-
- // Verify that secrets inside non-image objects ARE redacted.
- obj2 := map[string]any{
- testFieldType: "text",
- testFieldContent: highEntropySecret,
- }
- repls2 := collectJSONLReplacements(obj2)
- wantRepls2 := []jsonReplacement{{key: testFieldContent, original: highEntropySecret, redacted: wantRedacted}}
- if !slices.Equal(repls2, wantRepls2) {
- t.Errorf("got %q, want %q", repls2, wantRepls2)
- }
-}
-
-func TestString_FilePaths(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- input string
- want string
- }{
- {
- name: "temp directory path preserves filenames",
- input: testPathTmpE2E,
- want: testPathTmpE2E,
- },
- {
- name: "macOS private var folders path",
- input: testPathPrivateVar,
- want: testPathPrivateVar,
- },
- {
- name: "simple Go file path",
- input: "Reading file: /tmp/test/model.go",
- want: "Reading file: /tmp/test/model.go",
- },
- {
- name: "user home directory path",
- input: testPathUserClaude,
- want: testPathUserClaude,
- },
- {
- name: "multiple paths separated by newlines",
- input: testPathMultilineFiles,
- want: testPathMultilineFiles,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := String(tt.input)
- if got != tt.want {
- t.Errorf("String(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestString_JSONEscapeSequences(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- input string
- want string
- }{
- {
- name: "newline escape not corrupted",
- input: testJSONEscapeNewline,
- want: testJSONEscapeNewline,
- },
- {
- name: "tab escape not corrupted",
- input: testJSONEscapeTab,
- want: testJSONEscapeTab,
- },
- {
- name: "backslash escape not corrupted",
- input: testJSONEscapeBackslash,
- want: testJSONEscapeBackslash,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := String(tt.input)
- if got != tt.want {
- t.Errorf("String(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestString_RealSecretsStillCaught(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- input string
- }{
- {
- name: "high entropy API key",
- input: "api_key=" + highEntropySecret,
- },
- {
- name: "AWS access key (pattern-based)",
- input: "key=AKIAYRWQG5EJLPZLBYNP",
- },
- {
- name: "GitHub personal access token",
- input: "token=ghp_1234567890abcdefghijklmnopqrstuvwxyzAB",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := String(tt.input)
- if !strings.Contains(got, wantRedacted) {
- t.Errorf("String(%q) = %q, expected REDACTED somewhere", tt.input, got)
- }
- })
- }
-}
-
-func TestJSONLContent_PathFieldsPreserved(t *testing.T) {
- t.Parallel()
- // Simulates a real agent log line with path fields that should NOT be redacted
- input := `{"session_id":"ses_37273a1fdffegpYbwUTqEkPsQ0","file_path":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test/controller.go","cwd":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test","root":"/private/var/folders/v4/31cd3cg52_sfrpb1mbtr7q7r0000gn/T/test","directory":"/tmp/TestE2E_ExistingFiles","content":"normal text here"}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Structural fields should be preserved
- mustContain := []string{
- testSessionID, // session_id (skipped by *id rule)
- "/private/var/folders", // file_path (skipped by path rule)
- "controller.go", // filename in file_path
- "/tmp/TestE2E_ExistingFiles", // directory (skipped by path rule)
- }
- for _, s := range mustContain {
- if !strings.Contains(result, s) {
- t.Errorf("expected %q to be preserved, but result is: %s", s, result)
- }
- }
-
- // No false positives
- if strings.Contains(result, wantRedacted) {
- t.Errorf("expected no redactions in structural fields, got: %s", result)
- }
-}
-
-func TestJSONLContent_PrettyPrintedJSON_IDsPreserved(t *testing.T) {
- t.Parallel()
- // Simulates OpenCode's pretty-printed JSON export format.
- // High-entropy IDs (like msg_cb99a444f001Ftd3kTVmr8XQHZ with entropy > 4.5)
- // must be preserved. Before the fix, line-by-line processing couldn't parse
- // individual lines of pretty-printed JSON and fell back to entropy-based
- // redaction, corrupting these IDs.
- input := `{
- "info": {
- "id": "ses_309461a8bffeQfY7CYDOUHX6VP",
- "slug": "misty-river",
- "directory": "/tmp/test-repo"
- },
- "messages": [
- {
- "info": {
- "id": "msg_cb99a444f001Ftd3kTVmr8XQHZ",
- "sessionID": "ses_309461a8bffeQfY7CYDOUHX6VP",
- "role": "user"
- },
- "parts": [
- {
- "id": "prt_cb99a443b001GE99vjBG60vHbF",
- "type": "text",
- "text": "hello world"
- }
- ]
- },
- {
- "info": {
- "id": "msg_cb99a444f001Ftd3kTVmr8XQHZ",
- "sessionID": "ses_309461a8bffeQfY7CYDOUHX6VP",
- "role": "assistant"
- },
- "parts": [
- {
- "id": "prt_cb99a6f2e0012koCcOJBSwRBwR",
- "type": "text",
- "text": "hello back"
- },
- {
- "id": "prt_cb99a6f2f001e98CKuwDKU3oWr",
- "type": "tool",
- "tool": "write",
- "callID": "call_abc123",
- "state": {
- "status": "completed",
- "input": {"filePath": "/tmp/test/hello.md"},
- "output": "wrote file",
- "metadata": {"files": [{"filePath": "/tmp/test/hello.md", "relativePath": "hello.md"}]}
- }
- }
- ]
- }
- ]
-}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Verify the entropy threshold: msg_cb99a444f001Ftd3kTVmr8XQHZ has entropy > 4.5
- // and would be redacted by String() if processed line-by-line.
- entropy := shannonEntropy("msg_cb99a444f001Ftd3kTVmr8XQHZ")
- if entropy <= entropyThreshold {
- t.Fatalf("test assumption broken: msg ID entropy %.2f should be > %.1f", entropy, entropyThreshold)
- }
-
- // All IDs must be preserved (they're in "id"/"sessionID" fields which are skipped).
- mustContain := []string{
- "ses_309461a8bffeQfY7CYDOUHX6VP",
- "msg_cb99a444f001Ftd3kTVmr8XQHZ",
- "prt_cb99a443b001GE99vjBG60vHbF",
- "prt_cb99a6f2e0012koCcOJBSwRBwR",
- "prt_cb99a6f2f001e98CKuwDKU3oWr",
- }
- for _, s := range mustContain {
- if !strings.Contains(result, s) {
- t.Errorf("expected ID %q to be preserved, but it was corrupted in result", s)
- }
- }
-
- // No false positives on structural data.
- if strings.Contains(result, wantRedacted) {
- t.Errorf("expected no redactions in OpenCode export, got redacted content")
- }
-}
-
-func TestJSONLContent_PrettyPrintedJSON_SecretsStillCaught(t *testing.T) {
- t.Parallel()
- // Even in pretty-printed JSON mode, actual secrets in content fields should
- // still be redacted.
- input := `{
- "info": {
- "id": "ses_test123"
- },
- "messages": [
- {
- "info": {
- "id": "msg_test456",
- "role": "assistant"
- },
- "parts": [
- {
- "id": "prt_test789",
- "type": "text",
- "text": "your api key is ` + highEntropySecret + `"
- }
- ]
- }
- ]
-}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // Secret in text content should be redacted.
- if strings.Contains(result, highEntropySecret) {
- t.Error("secret in text field was not redacted")
- }
- if !strings.Contains(result, wantRedacted) {
- t.Error("expected REDACTED in output")
- }
-
- // IDs should still be preserved.
- for _, id := range []string{"ses_test123", "msg_test456", "prt_test789"} {
- if !strings.Contains(result, id) {
- t.Errorf("ID %q should be preserved", id)
- }
- }
-}
-
-func TestJSONLContent_SecretsInContentStillCaught(t *testing.T) {
- t.Parallel()
- // Path fields should be preserved, but secrets in content should be caught
- input := `{"file_path":"/tmp/test.go","content":"api_key=` + highEntropySecret + `"}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- // file_path should be preserved
- if !strings.Contains(result, "/tmp/test.go") {
- t.Error("file_path was incorrectly modified")
- }
-
- // Secret in content should be redacted
- if strings.Contains(result, highEntropySecret) {
- t.Error("secret in content field was not redacted")
- }
- if !strings.Contains(result, wantRedacted) {
- t.Error("expected REDACTED in output")
- }
-}
-
-// Pins a known gap: shell shorthand `--password=...` is not redacted because
-// no detector matches `--password=` (no DB-prefix, no DSN structure, no URI).
-func TestString_MysqlShellShorthandIsNotRedacted(t *testing.T) {
- t.Parallel()
- assertStringRedactionCases(t, []stringRedactionCase{
- {
- name: "mysql cli flag",
- input: "mysql -u svc --password=hunter2 -h db.example.com app",
- want: "mysql -u svc --password=hunter2 -h db.example.com app",
- },
- {
- name: "psql cli flag",
- input: "psql --password=hunter2 -U svc -h db.example.com app",
- want: "psql --password=hunter2 -U svc -h db.example.com app",
- },
- })
-}
-
-// Pins f(f(x)) == f(x): once-redacted output must not match any detector on
-// a second pass.
-func TestString_RedactionIsIdempotent(t *testing.T) {
- t.Parallel()
- inputs := []string{
- "DATABASE_URL=postgres://svc:hunter2@db.example.com/app",
- "DB_PASSWORD=hunter2",
- `conn=Server=db.example.com;User ID=svc;Password="se;cret;here";Encrypt=true`,
- "jdbc:postgresql://db.example.com:5432/app?user=svc&password=hunter2",
- "my key is " + highEntropySecret + " ok",
- }
- for _, input := range inputs {
- t.Run(input, func(t *testing.T) {
- t.Parallel()
- once := String(input)
- twice := String(once)
- if once != twice {
- t.Errorf("not idempotent for %q:\n once: %q\n twice: %q", input, once, twice)
- }
- })
- }
-}
-
-// Pins keyed-JSON replacement as (key, value) rather than (path, value): a
-// shared value under the same key name redacts in every context, not just
-// the credential one. Conservative on purpose — flag if changed.
-func TestJSONLContent_CrossContextValueCollision(t *testing.T) {
- t.Parallel()
- input := `{"db":{"host":"db.example.com","user":"svc","password":"shared-secret"},"misc":{"password":"shared-secret"}}`
-
- result, err := JSONLContent(input)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if strings.Contains(result, "shared-secret") {
- t.Errorf("expected shared-secret to be redacted in both contexts, got: %s", result)
- }
- if strings.Count(result, `"password":"`+wantRedacted+`"`) != 2 {
- t.Errorf("expected both password fields redacted, got: %s", result)
- }
-}
]