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) - } -}