From 734fa1a32a6bd35dcb10ee394025315265d9f93d Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 15:59:23 +0100 Subject: [PATCH 01/22] full e2e tests using real agent, basic tests Entire-Checkpoint: bccb77bb88fa --- cmd/entire/cli/e2e_test/agent_runner.go | 247 ++++++++ cmd/entire/cli/e2e_test/assertions.go | 158 +++++ cmd/entire/cli/e2e_test/prompts.go | 95 +++ .../e2e_test/scenario_agent_commit_test.go | 113 ++++ .../e2e_test/scenario_basic_workflow_test.go | 87 +++ .../cli/e2e_test/scenario_checkpoint_test.go | 116 ++++ .../cli/e2e_test/scenario_rewind_test.go | 145 +++++ cmd/entire/cli/e2e_test/setup_test.go | 101 ++++ cmd/entire/cli/e2e_test/testenv.go | 541 ++++++++++++++++++ mise.toml | 12 + 10 files changed, 1615 insertions(+) create mode 100644 cmd/entire/cli/e2e_test/agent_runner.go create mode 100644 cmd/entire/cli/e2e_test/assertions.go create mode 100644 cmd/entire/cli/e2e_test/prompts.go create mode 100644 cmd/entire/cli/e2e_test/scenario_agent_commit_test.go create mode 100644 cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go create mode 100644 cmd/entire/cli/e2e_test/scenario_checkpoint_test.go create mode 100644 cmd/entire/cli/e2e_test/scenario_rewind_test.go create mode 100644 cmd/entire/cli/e2e_test/setup_test.go create mode 100644 cmd/entire/cli/e2e_test/testenv.go diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go new file mode 100644 index 000000000..63dc7373c --- /dev/null +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -0,0 +1,247 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// AgentNameClaudeCode is the name for Claude Code agent. +const AgentNameClaudeCode = "claude-code" + +// AgentNameGeminiCLI is the name for Gemini CLI agent. +const AgentNameGeminiCLI = "gemini-cli" + +// AgentRunner abstracts invoking a coding agent for e2e tests. +// This follows the multi-agent pattern from cmd/entire/cli/agent/agent.go. +type AgentRunner interface { + // Name returns the agent name (e.g., "claude-code", "gemini-cli") + Name() string + + // IsAvailable checks if the agent CLI is installed and authenticated + IsAvailable() (bool, error) + + // RunPrompt executes a prompt and returns the result + RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) + + // RunPromptWithTools executes with specific allowed tools + RunPromptWithTools(ctx context.Context, workDir string, prompt string, tools []string) (*AgentResult, error) +} + +// AgentResult holds the result of an agent invocation. +type AgentResult struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration +} + +// AgentRunnerConfig holds configuration for agent runners. +type AgentRunnerConfig struct { + Model string // Model to use (e.g., "haiku" for Claude) + Timeout time.Duration // Timeout per prompt +} + +// NewAgentRunner creates an agent runner based on the agent name. +// +//nolint:ireturn // factory pattern intentionally returns interface +func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { + switch name { + case AgentNameClaudeCode: + return NewClaudeCodeRunner(config) + case AgentNameGeminiCLI: + return NewGeminiCLIRunner(config) + default: + // Return a runner that reports as unavailable + return &unavailableRunner{name: name} + } +} + +// unavailableRunner is returned for unknown agent names. +type unavailableRunner struct { + name string +} + +func (r *unavailableRunner) Name() string { return r.name } + +func (r *unavailableRunner) IsAvailable() (bool, error) { + return false, fmt.Errorf("unknown agent: %s", r.name) +} + +func (r *unavailableRunner) RunPrompt(_ context.Context, _ string, _ string) (*AgentResult, error) { + return nil, fmt.Errorf("agent %s is not available", r.name) +} + +func (r *unavailableRunner) RunPromptWithTools(_ context.Context, _ string, _ string, _ []string) (*AgentResult, error) { + return nil, fmt.Errorf("agent %s is not available", r.name) +} + +// ClaudeCodeRunner implements AgentRunner for Claude Code CLI. +type ClaudeCodeRunner struct { + Model string + Timeout time.Duration + AllowedTools []string +} + +// NewClaudeCodeRunner creates a new Claude Code runner with the given config. +func NewClaudeCodeRunner(config AgentRunnerConfig) *ClaudeCodeRunner { + model := config.Model + if model == "" { + model = os.Getenv("E2E_CLAUDE_MODEL") + if model == "" { + model = "haiku" + } + } + + timeout := config.Timeout + if timeout == 0 { + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if parsed, err := time.ParseDuration(envTimeout); err == nil { + timeout = parsed + } + } + if timeout == 0 { + timeout = 2 * time.Minute + } + } + + return &ClaudeCodeRunner{ + Model: model, + Timeout: timeout, + AllowedTools: []string{"Edit", "Read", "Write", "Bash", "Glob", "Grep"}, + } +} + +func (r *ClaudeCodeRunner) Name() string { + return AgentNameClaudeCode +} + +// IsAvailable checks if Claude CLI is installed and working. +// Note: Claude Code uses OAuth authentication (via `claude login`), not ANTHROPIC_API_KEY. +func (r *ClaudeCodeRunner) IsAvailable() (bool, error) { + // Check if claude CLI is in PATH + if _, err := exec.LookPath("claude"); err != nil { + return false, fmt.Errorf("claude CLI not found in PATH: %w", err) + } + + // Check if claude is working (--version doesn't require auth) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("claude CLI not working: %w", err) + } + + return true, nil +} + +func (r *ClaudeCodeRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, r.AllowedTools) +} + +func (r *ClaudeCodeRunner) RunPromptWithTools(ctx context.Context, workDir string, prompt string, tools []string) (*AgentResult, error) { + // Build command: claude --model -p "" --allowedTools + args := []string{ + "--model", r.Model, + "-p", prompt, + } + + if len(tools) > 0 { + args = append(args, "--allowedTools", strings.Join(tools, ",")) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(ctx, r.Timeout) + defer cancel() + + //nolint:gosec // args are constructed from trusted config, not user input + cmd := exec.CommandContext(ctx, "claude", args...) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + result := &AgentResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: duration, + } + + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + //nolint:wrapcheck // error is from exec.Run, caller can check ExitCode in result + return result, err + } + + result.ExitCode = 0 + return result, nil +} + +// GeminiCLIRunner implements AgentRunner for Gemini CLI. +// This is a placeholder for future implementation. +type GeminiCLIRunner struct { + Timeout time.Duration +} + +// NewGeminiCLIRunner creates a new Gemini CLI runner with the given config. +func NewGeminiCLIRunner(config AgentRunnerConfig) *GeminiCLIRunner { + timeout := config.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + + return &GeminiCLIRunner{ + Timeout: timeout, + } +} + +func (r *GeminiCLIRunner) Name() string { + return AgentNameGeminiCLI +} + +// IsAvailable checks if Gemini CLI is installed and authenticated. +func (r *GeminiCLIRunner) IsAvailable() (bool, error) { + // Check if gemini CLI is in PATH + if _, err := exec.LookPath("gemini"); err != nil { + return false, fmt.Errorf("gemini CLI not found in PATH: %w", err) + } + + // Check if gemini is working + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "gemini", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("gemini CLI not working: %w", err) + } + + return true, nil +} + +func (r *GeminiCLIRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, nil) +} + +func (r *GeminiCLIRunner) RunPromptWithTools(_ context.Context, _ string, _ string, _ []string) (*AgentResult, error) { + // Gemini CLI implementation would go here + // For now, return an error indicating it's not fully implemented + return nil, errors.New("gemini CLI runner not yet implemented") +} diff --git a/cmd/entire/cli/e2e_test/assertions.go b/cmd/entire/cli/e2e_test/assertions.go new file mode 100644 index 000000000..fa3a522f9 --- /dev/null +++ b/cmd/entire/cli/e2e_test/assertions.go @@ -0,0 +1,158 @@ +//go:build e2e + +package e2e + +import ( + "regexp" + "strings" + "testing" +) + +// AssertFileContains checks that a file contains the expected substring. +func AssertFileContains(t *testing.T, env *TestEnv, path, expected string) { + t.Helper() + + content := env.ReadFile(path) + if !strings.Contains(content, expected) { + t.Errorf("File %s does not contain expected string.\nExpected substring: %q\nActual content:\n%s", + path, expected, content) + } +} + +// AssertFileMatches checks that a file content matches a regex pattern. +func AssertFileMatches(t *testing.T, env *TestEnv, path, pattern string) { + t.Helper() + + content := env.ReadFile(path) + matched, err := regexp.MatchString(pattern, content) + if err != nil { + t.Fatalf("Invalid regex pattern %q: %v", pattern, err) + } + if !matched { + t.Errorf("File %s does not match pattern.\nPattern: %q\nActual content:\n%s", + path, pattern, content) + } +} + +// AssertHelloWorldProgram verifies a Go file is a valid hello world program. +// Uses flexible matching to handle agent variations. +func AssertHelloWorldProgram(t *testing.T, env *TestEnv, path string) { + t.Helper() + + content := env.ReadFile(path) + + // Check for package main + if !strings.Contains(content, "package main") { + t.Errorf("File %s missing 'package main'", path) + } + + // Check for main function (flexible whitespace) + mainFuncPattern := regexp.MustCompile(`func\s+main\s*\(\s*\)`) + if !mainFuncPattern.MatchString(content) { + t.Errorf("File %s missing main function", path) + } + + // Check for Hello, World! (case insensitive) + helloPattern := regexp.MustCompile(`(?i)hello.+world`) + if !helloPattern.MatchString(content) { + t.Errorf("File %s missing 'Hello, World!' output", path) + } +} + +// AssertCalculatorFunctions verifies calc.go has the expected functions. +func AssertCalculatorFunctions(t *testing.T, env *TestEnv, path string, functions ...string) { + t.Helper() + + content := env.ReadFile(path) + + for _, fn := range functions { + // Check for function definition (flexible whitespace) + pattern := regexp.MustCompile(`func\s+` + regexp.QuoteMeta(fn) + `\s*\(`) + if !pattern.MatchString(content) { + t.Errorf("File %s missing function %s", path, fn) + } + } +} + +// AssertRewindPointExists checks that at least one rewind point exists. +func AssertRewindPointExists(t *testing.T, env *TestEnv) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) == 0 { + t.Error("Expected at least one rewind point, but none exist") + } +} + +// AssertRewindPointCount checks that the expected number of rewind points exist. +func AssertRewindPointCount(t *testing.T, env *TestEnv, expected int) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) != expected { + t.Errorf("Expected %d rewind points, got %d", expected, len(points)) + for i, p := range points { + t.Logf(" Point %d: ID=%s, Message=%s", i, p.ID, p.Message) + } + } +} + +// AssertRewindPointCountAtLeast checks that at least the expected number of rewind points exist. +func AssertRewindPointCountAtLeast(t *testing.T, env *TestEnv, minimum int) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) < minimum { + t.Errorf("Expected at least %d rewind points, got %d", minimum, len(points)) + for i, p := range points { + t.Logf(" Point %d: ID=%s, Message=%s", i, p.ID, p.Message) + } + } +} + +// AssertCheckpointExists checks that a checkpoint trailer exists in commit history. +func AssertCheckpointExists(t *testing.T, env *TestEnv) { + t.Helper() + + checkpointID := env.GetLatestCheckpointIDFromHistory() + if checkpointID == "" { + t.Error("Expected checkpoint trailer in commit history, but none found") + } +} + +// AssertBranchExists checks that a branch exists. +func AssertBranchExists(t *testing.T, env *TestEnv, branchName string) { + t.Helper() + + if !env.BranchExists(branchName) { + t.Errorf("Expected branch %s to exist, but it doesn't", branchName) + } +} + +// AssertAgentSuccess checks that an agent result indicates success. +func AssertAgentSuccess(t *testing.T, result *AgentResult, err error) { + t.Helper() + + if err != nil { + stderr := "" + if result != nil { + stderr = result.Stderr + } + t.Errorf("Agent failed with error: %v\nStderr: %s", err, stderr) + } + if result != nil && result.ExitCode != 0 { + t.Errorf("Agent exited with code %d\nStdout: %s\nStderr: %s", + result.ExitCode, result.Stdout, result.Stderr) + } +} + +// AssertExpectedFilesExist checks that all expected files from a prompt template exist. +func AssertExpectedFilesExist(t *testing.T, env *TestEnv, prompt PromptTemplate) { + t.Helper() + + for _, file := range prompt.ExpectedFiles { + if !env.FileExists(file) { + t.Errorf("Expected file %s to exist after prompt %s, but it doesn't", file, prompt.Name) + } + } +} diff --git a/cmd/entire/cli/e2e_test/prompts.go b/cmd/entire/cli/e2e_test/prompts.go new file mode 100644 index 000000000..622051499 --- /dev/null +++ b/cmd/entire/cli/e2e_test/prompts.go @@ -0,0 +1,95 @@ +//go:build e2e + +package e2e + +// PromptTemplate defines a deterministic prompt with expected outcomes. +type PromptTemplate struct { + Name string // Unique name for the prompt + Prompt string // The actual prompt text + ExpectedFiles []string // Files expected to be created/modified +} + +// Deterministic prompts designed for predictable outcomes with minimal token usage. + +// PromptCreateHelloGo creates a simple Go hello world program. +var PromptCreateHelloGo = PromptTemplate{ + Name: "CreateHelloGo", + Prompt: `Create a file called hello.go with a simple Go program that prints "Hello, World!". +Requirements: +- Use package main +- Use a main function +- Use fmt.Println to print exactly "Hello, World!" +- Do not add comments, tests, or extra functionality +- Do not create any other files`, + ExpectedFiles: []string{"hello.go"}, +} + +// PromptModifyHelloGo modifies the hello.go file to print a different message. +var PromptModifyHelloGo = PromptTemplate{ + Name: "ModifyHelloGo", + Prompt: `Modify hello.go to print "Hello, E2E Test!" instead of "Hello, World!". +Do not add any other functionality or files.`, + ExpectedFiles: []string{"hello.go"}, +} + +// PromptCreateCalculator creates a simple calculator with add/subtract functions. +var PromptCreateCalculator = PromptTemplate{ + Name: "CreateCalculator", + Prompt: `Create a file called calc.go with two exported functions: +- Add(a, b int) int - returns a + b +- Subtract(a, b int) int - returns a - b +Requirements: +- Use package main +- No comments or documentation +- No main function +- No tests +- No other files`, + ExpectedFiles: []string{"calc.go"}, +} + +// PromptCreateConfig creates a simple JSON config file. +var PromptCreateConfig = PromptTemplate{ + Name: "CreateConfig", + Prompt: `Create a file called config.json with this exact content: +{ + "name": "e2e-test", + "version": "1.0.0", + "enabled": true +} +Do not create any other files.`, + ExpectedFiles: []string{"config.json"}, +} + +// PromptAddMultiplyFunction adds a multiply function to calc.go. +var PromptAddMultiplyFunction = PromptTemplate{ + Name: "AddMultiplyFunction", + Prompt: `Add a Multiply function to calc.go that multiplies two integers. +- Signature: Multiply(a, b int) int +- No comments +- No other changes`, + ExpectedFiles: []string{"calc.go"}, +} + +// PromptCreateREADME creates a simple README file. +var PromptCreateREADME = PromptTemplate{ + Name: "CreateREADME", + Prompt: `Create a file called DOCS.md with exactly this content: +# Documentation + +This is the documentation file. + +## Usage + +Run the program with: go run . +`, + ExpectedFiles: []string{"DOCS.md"}, +} + +// PromptCommitChanges instructs the agent to commit changes. +// This is used to test agent commits during a turn. +var PromptCommitChanges = PromptTemplate{ + Name: "CommitChanges", + Prompt: `Stage and commit all changes with the message "Add feature via agent". +Use git add and git commit commands.`, + ExpectedFiles: []string{}, +} diff --git a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go new file mode 100644 index 000000000..c2fb77d9a --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go @@ -0,0 +1,113 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestE2E_AgentCommitsDuringTurn tests what happens when the agent commits during its turn. +// This is a P1 test because it tests the deferred finalization behavior. +func TestE2E_AgentCommitsDuringTurn(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. First, agent creates a file + t.Log("Step 1: Agent creating file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // 2. Agent commits the changes (using Bash tool) + t.Log("Step 2: Agent committing changes") + commitPrompt := `Stage and commit the hello.go file with commit message "Add hello world via agent". +Use these exact commands: +1. git add hello.go +2. git commit -m "Add hello world via agent" +Only run these two commands, nothing else.` + + result, err = env.RunAgentWithTools(commitPrompt, []string{"Bash"}) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + t.Logf("Agent commit output: %s", result.Stdout) + + // 3. Verify the commit was made + t.Log("Step 3: Verifying commit was made") + headMsg := env.GetCommitMessage(env.GetHeadHash()) + t.Logf("HEAD commit message: %s", headMsg) + + // The commit might or might not have the Entire-Checkpoint trailer depending + // on hook configuration. The key thing is the commit was made. + + // 4. Check rewind points + t.Log("Step 4: Checking rewind points") + points := env.GetRewindPoints() + t.Logf("Found %d rewind points after agent commit", len(points)) + + // 5. Agent makes another change after committing + t.Log("Step 5: Agent making another change") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("calc.go")) + + // 6. User commits the second change + t.Log("Step 6: User committing second change") + env.GitCommitWithShadowHooks("Add calculator", "calc.go") + + // 7. Final verification + checkpointID := env.GetLatestCheckpointIDFromHistory() + t.Logf("Final checkpoint ID: %s", checkpointID) +} + +// TestE2E_MultipleAgentSessions tests behavior across multiple agent sessions. +func TestE2E_MultipleAgentSessions(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Session 1: Create hello.go + t.Log("Session 1: Creating hello.go") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + session1Points := env.GetRewindPoints() + t.Logf("After session 1: %d rewind points", len(session1Points)) + + // User commits + env.GitCommitWithShadowHooks("Session 1: Add hello world", "hello.go") + + // Session 2: Create calc.go + t.Log("Session 2: Creating calc.go") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + session2Points := env.GetRewindPoints() + t.Logf("After session 2: %d rewind points", len(session2Points)) + + // User commits + env.GitCommitWithShadowHooks("Session 2: Add calculator", "calc.go") + + // Session 3: Add multiply function + t.Log("Session 3: Adding multiply function") + result, err = env.RunAgent(PromptAddMultiplyFunction.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify multiply function was added + AssertCalculatorFunctions(t, env, "calc.go", "Add", "Subtract", "Multiply") + + // User commits + env.GitCommitWithShadowHooks("Session 3: Add multiply function", "calc.go") + + // Final check: we should have checkpoint IDs in commit history + t.Log("Final verification") + checkpointID := env.GetLatestCheckpointIDFromHistory() + require.NotEmpty(t, checkpointID, "Should have checkpoint in final commit") +} diff --git a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go new file mode 100644 index 000000000..78d9a6646 --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go @@ -0,0 +1,87 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_BasicWorkflow tests the fundamental workflow: +// Agent creates a file -> User commits -> Checkpoint is created +func TestE2E_BasicWorkflow(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates a file + t.Log("Step 1: Running agent to create hello.go") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err, "Agent should succeed") + AssertAgentSuccess(t, result, err) + t.Logf("Agent completed in %v", result.Duration) + + // 2. Verify file was created with expected content + t.Log("Step 2: Verifying file was created") + require.True(t, env.FileExists("hello.go"), "hello.go should exist") + AssertHelloWorldProgram(t, env, "hello.go") + + // 3. Verify rewind points exist (session should have created checkpoints) + t.Log("Step 3: Checking for rewind points") + points := env.GetRewindPoints() + assert.GreaterOrEqual(t, len(points), 1, "Should have at least 1 rewind point") + if len(points) > 0 { + t.Logf("Found %d rewind point(s), first: %s", len(points), points[0].Message) + } + + // 4. User commits the changes with hooks + t.Log("Step 4: Committing changes with hooks") + env.GitCommitWithShadowHooks("Add hello world program", "hello.go") + + // 5. Verify checkpoint was created (trailer in commit) + t.Log("Step 5: Verifying checkpoint") + checkpointID := env.GetLatestCheckpointIDFromHistory() + assert.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") + t.Logf("Checkpoint ID: %s", checkpointID) + + // 6. Verify metadata branch exists + t.Log("Step 6: Checking metadata branch") + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_MultipleChanges tests multiple agent changes before commit. +func TestE2E_MultipleChanges(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. First agent action: create hello.go + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // 2. Second agent action: create calc.go + t.Log("Step 2: Creating second file") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("calc.go")) + + // 3. Verify multiple rewind points exist + t.Log("Step 3: Checking rewind points") + points := env.GetRewindPoints() + assert.GreaterOrEqual(t, len(points), 2, "Should have at least 2 rewind points") + + // 4. Commit both files + t.Log("Step 4: Committing all changes") + env.GitCommitWithShadowHooks("Add hello world and calculator", "hello.go", "calc.go") + + // 5. Verify checkpoint + checkpointID := env.GetLatestCheckpointIDFromHistory() + assert.NotEmpty(t, checkpointID) +} diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go new file mode 100644 index 000000000..69464d345 --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go @@ -0,0 +1,116 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_CheckpointMetadata verifies that checkpoint metadata is correctly stored. +func TestE2E_CheckpointMetadata(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates a file + t.Log("Step 1: Agent creating file") + result, err := env.RunAgent(PromptCreateConfig.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + AssertExpectedFilesExist(t, env, PromptCreateConfig) + + // 2. Verify session created rewind points + t.Log("Step 2: Checking session rewind points") + points := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points), 1, "Should have rewind points before commit") + + // Note: Before commit, points are on the shadow branch + // They should have metadata directories set + for i, p := range points { + t.Logf("Rewind point %d: ID=%s, MetadataDir=%s, Message=%s", + i, p.ID[:12], p.MetadataDir, p.Message) + } + + // 3. User commits + t.Log("Step 3: Committing changes") + env.GitCommitWithShadowHooks("Add config file", "config.json") + + // 4. Verify checkpoint trailer added + checkpointID := env.GetLatestCheckpointIDFromHistory() + require.NotEmpty(t, checkpointID, "Should have checkpoint ID in commit") + t.Logf("Checkpoint ID: %s", checkpointID) + + // 5. Verify metadata branch has content + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "Metadata branch should exist after commit") + + // 6. Verify rewind points now reference condensed metadata + t.Log("Step 4: Checking post-commit rewind points") + postPoints := env.GetRewindPoints() + // After commit, logs-only points from entire/checkpoints/v1 should exist + for i, p := range postPoints { + t.Logf("Post-commit point %d: ID=%s, IsLogsOnly=%v, CondensationID=%s", + i, p.ID[:12], p.IsLogsOnly, p.CondensationID) + } +} + +// TestE2E_CheckpointIDFormat verifies checkpoint ID format is correct. +func TestE2E_CheckpointIDFormat(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent makes changes + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // 2. User commits + env.GitCommitWithShadowHooks("Add hello world", "hello.go") + + // 3. Verify checkpoint ID format + checkpointID := env.GetLatestCheckpointIDFromHistory() + require.NotEmpty(t, checkpointID) + + // Checkpoint ID should be 12 hex characters + assert.Len(t, checkpointID, 12, "Checkpoint ID should be 12 characters") + + // Should only contain hex characters + for _, c := range checkpointID { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), + "Checkpoint ID should be lowercase hex: got %c", c) + } +} + +// TestE2E_AutoCommitStrategy tests the auto-commit strategy creates clean commits. +func TestE2E_AutoCommitStrategy(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "auto-commit") + + // 1. Agent creates a file + t.Log("Step 1: Agent creating file with auto-commit strategy") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // 2. Verify file exists + require.True(t, env.FileExists("hello.go")) + + // 3. With auto-commit, commits are created automatically + // Check if commits were made with checkpoint trailers + commitMsg := env.GetCommitMessage(env.GetHeadHash()) + t.Logf("Latest commit message: %s", commitMsg) + + // 4. Verify metadata branch exists + if env.BranchExists("entire/checkpoints/v1") { + t.Log("Metadata branch exists (auto-commit creates it)") + } + + // 5. Check for rewind points + points := env.GetRewindPoints() + t.Logf("Found %d rewind points", len(points)) +} diff --git a/cmd/entire/cli/e2e_test/scenario_rewind_test.go b/cmd/entire/cli/e2e_test/scenario_rewind_test.go new file mode 100644 index 000000000..d4d6413b4 --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_rewind_test.go @@ -0,0 +1,145 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_RewindToCheckpoint tests rewinding to a previous checkpoint. +func TestE2E_RewindToCheckpoint(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates first file + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // Get first checkpoint + points1 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points1), 1) + firstPointID := points1[0].ID + t.Logf("First checkpoint: %s", firstPointID[:12]) + + // Save original content + originalContent := env.ReadFile("hello.go") + + // 2. Agent modifies the file + t.Log("Step 2: Modifying file") + result, err = env.RunAgent(PromptModifyHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify content changed + modifiedContent := env.ReadFile("hello.go") + assert.NotEqual(t, originalContent, modifiedContent, "Content should have changed") + assert.Contains(t, modifiedContent, "E2E Test", "Should contain new message") + + // Get second checkpoint + points2 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points2), 2, "Should have at least 2 checkpoints") + t.Logf("Now have %d checkpoints", len(points2)) + + // 3. Rewind to first checkpoint + t.Log("Step 3: Rewinding to first checkpoint") + err = env.Rewind(firstPointID) + require.NoError(t, err) + + // 4. Verify content was restored + t.Log("Step 4: Verifying content restored") + restoredContent := env.ReadFile("hello.go") + assert.Equal(t, originalContent, restoredContent, "Content should be restored to original") + assert.NotContains(t, restoredContent, "E2E Test", "Should not contain modified message") +} + +// TestE2E_RewindAfterCommit tests rewinding to a checkpoint after user commits. +func TestE2E_RewindAfterCommit(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates file + t.Log("Step 1: Creating file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Get checkpoint before commit + pointsBefore := env.GetRewindPoints() + require.GreaterOrEqual(t, len(pointsBefore), 1) + preCommitPointID := pointsBefore[0].ID + + // 2. User commits + t.Log("Step 2: Committing") + env.GitCommitWithShadowHooks("Add hello world", "hello.go") + + // 3. Agent modifies file (new session) + t.Log("Step 3: Modifying file after commit") + result, err = env.RunAgent(PromptModifyHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + modifiedContent := env.ReadFile("hello.go") + require.Contains(t, modifiedContent, "E2E Test") + + // 4. Get rewind points - should include both pre and post commit points + t.Log("Step 4: Getting rewind points") + points := env.GetRewindPoints() + t.Logf("Found %d rewind points", len(points)) + for i, p := range points { + t.Logf(" Point %d: %s (logs_only=%v, condensation_id=%s)", + i, p.ID[:12], p.IsLogsOnly, p.CondensationID) + } + + // 5. Rewind to pre-commit checkpoint + t.Log("Step 5: Rewinding to pre-commit checkpoint") + err = env.Rewind(preCommitPointID) + // Note: After commit, rewinding to a pre-commit checkpoint may only restore logs + // depending on the checkpoint's state + if err != nil { + t.Logf("Rewind result: %v (may be expected for logs-only points)", err) + } +} + +// TestE2E_RewindMultipleFiles tests rewinding changes across multiple files. +func TestE2E_RewindMultipleFiles(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates multiple files + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Get checkpoint after first file + points1 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points1), 1) + afterFirstFile := points1[0].ID + + t.Log("Step 2: Creating second file") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify both files exist + require.True(t, env.FileExists("hello.go")) + require.True(t, env.FileExists("calc.go")) + + // 3. Rewind to after first file (before second) + t.Log("Step 3: Rewinding to after first file") + err = env.Rewind(afterFirstFile) + require.NoError(t, err) + + // 4. Verify only first file exists + assert.True(t, env.FileExists("hello.go"), "hello.go should still exist") + assert.False(t, env.FileExists("calc.go"), "calc.go should be removed by rewind") +} diff --git a/cmd/entire/cli/e2e_test/setup_test.go b/cmd/entire/cli/e2e_test/setup_test.go new file mode 100644 index 000000000..0c26243cd --- /dev/null +++ b/cmd/entire/cli/e2e_test/setup_test.go @@ -0,0 +1,101 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// testBinaryPath holds the path to the CLI binary built once in TestMain. +// All tests share this binary to avoid repeated builds. +var testBinaryPath string + +// defaultAgent holds the agent to test with, determined in TestMain. +var defaultAgent string + +// TestMain builds the CLI binary once and checks agent availability before running tests. +func TestMain(m *testing.M) { + // Determine which agent to test with + defaultAgent = os.Getenv("E2E_AGENT") + if defaultAgent == "" { + defaultAgent = AgentNameClaudeCode + } + + // Check if the agent is available + runner := NewAgentRunner(defaultAgent, AgentRunnerConfig{}) + available, err := runner.IsAvailable() + if !available { + fmt.Printf("Agent %s not available (%v), skipping E2E tests\n", defaultAgent, err) + // Exit 0 to not fail CI when agent isn't configured + os.Exit(0) + } + + // Build binary once to a temp directory + tmpDir, err := os.MkdirTemp("", "entire-e2e-test-*") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create temp dir for binary: %v\n", err) + os.Exit(1) + } + + testBinaryPath = filepath.Join(tmpDir, "entire") + + moduleRoot := findModuleRoot() + ctx := context.Background() + + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", testBinaryPath, ".") + buildCmd.Dir = filepath.Join(moduleRoot, "cmd", "entire") + + buildOutput, err := buildCmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build CLI binary: %v\nOutput: %s\n", err, buildOutput) + os.RemoveAll(tmpDir) + os.Exit(1) + } + + // Add binary to PATH so hooks can find it + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+origPath) + + // Run tests + code := m.Run() + + // Cleanup + os.Setenv("PATH", origPath) + os.RemoveAll(tmpDir) + os.Exit(code) +} + +// getTestBinary returns the path to the shared test binary. +// It panics if TestMain hasn't run (testBinaryPath is empty). +func getTestBinary() string { + if testBinaryPath == "" { + panic("testBinaryPath not set - TestMain must run before tests") + } + return testBinaryPath +} + +// findModuleRoot finds the Go module root by walking up from the current file. +func findModuleRoot() string { + _, 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/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go new file mode 100644 index 000000000..8668d7b97 --- /dev/null +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -0,0 +1,541 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/format/config" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// TestEnv manages an isolated test environment for E2E tests with real agent calls. +type TestEnv struct { + T *testing.T + RepoDir string + Agent AgentRunner +} + +// NewTestEnv creates a new isolated E2E test environment. +func NewTestEnv(t *testing.T) *TestEnv { + t.Helper() + + // Resolve symlinks on macOS where /var -> /private/var + repoDir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(repoDir); err == nil { + repoDir = resolved + } + + // Create agent runner + agent := NewAgentRunner(defaultAgent, AgentRunnerConfig{}) + + return &TestEnv{ + T: t, + RepoDir: repoDir, + Agent: agent, + } +} + +// NewFeatureBranchEnv creates an E2E test environment ready for testing. +// It initializes the repo, creates an initial commit on main, +// checks out a feature branch, and sets up agent hooks. +func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { + t.Helper() + + env := NewTestEnv(t) + env.InitRepo() + env.WriteFile("README.md", "# Test Repository\n\nThis is a test repository for E2E testing.\n") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/e2e-test") + + // Use `entire enable` to set up everything (hooks, settings, etc.) + // This sets up .entire/settings.json and .claude/settings.json with hooks + env.RunEntireEnable(strategyName) + + return env +} + +// RunEntireEnable runs `entire enable` to set up the project with hooks. +func (env *TestEnv) RunEntireEnable(strategyName string) { + env.T.Helper() + + args := []string{ + "enable", + "--agent", "claude-code", + "--strategy", strategyName, + "--telemetry=false", + "--force", // Force reinstall hooks in case they exist + } + + //nolint:gosec,noctx // test code, args are static + cmd := exec.Command(getTestBinary(), args...) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + env.T.Fatalf("entire enable failed: %v\nOutput: %s", err, output) + } + env.T.Logf("entire enable output: %s", output) +} + +// InitRepo initializes a git repository in the test environment. +func (env *TestEnv) InitRepo() { + env.T.Helper() + + repo, err := git.PlainInit(env.RepoDir, false) + if err != nil { + env.T.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for commits + cfg, err := repo.Config() + if err != nil { + env.T.Fatalf("failed to get repo config: %v", err) + } + cfg.User.Name = "E2E Test User" + cfg.User.Email = "e2e-test@example.com" + + // Disable GPG signing for test commits + if cfg.Raw == nil { + cfg.Raw = config.New() + } + cfg.Raw.Section("commit").SetOption("gpgsign", "false") + + if err := repo.SetConfig(cfg); err != nil { + env.T.Fatalf("failed to set repo config: %v", err) + } +} + +// InitEntire initializes the .entire directory with the specified strategy. +func (env *TestEnv) InitEntire(strategyName string) { + env.T.Helper() + + // Create .entire directory structure + entireDir := filepath.Join(env.RepoDir, ".entire") + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(entireDir, 0o755); err != nil { + env.T.Fatalf("failed to create .entire directory: %v", err) + } + + // Create tmp directory + tmpDir := filepath.Join(entireDir, "tmp") + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + env.T.Fatalf("failed to create .entire/tmp directory: %v", err) + } + + // Write settings.json + settings := map[string]any{ + "strategy": strategyName, + "local_dev": true, // Use go run for hooks in tests + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + env.T.Fatalf("failed to marshal settings: %v", err) + } + data = append(data, '\n') + settingsPath := filepath.Join(entireDir, "settings.json") + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(settingsPath, data, 0o644); err != nil { + env.T.Fatalf("failed to write settings.json: %v", err) + } +} + +// WriteFile creates a file with the given content in the test repo. +func (env *TestEnv) WriteFile(path, content string) { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + + // Create parent directories + dir := filepath.Dir(fullPath) + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(dir, 0o755); err != nil { + env.T.Fatalf("failed to create directory %s: %v", dir, err) + } + + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + env.T.Fatalf("failed to write file %s: %v", path, err) + } +} + +// ReadFile reads a file from the test repo. +func (env *TestEnv) ReadFile(path string) string { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + env.T.Fatalf("failed to read file %s: %v", path, err) + } + return string(data) +} + +// TryReadFile reads a file from the test repo, returning empty string if not found. +func (env *TestEnv) TryReadFile(path string) string { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + return string(data) +} + +// FileExists checks if a file exists in the test repo. +func (env *TestEnv) FileExists(path string) bool { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + _, err := os.Stat(fullPath) + return err == nil +} + +// GitAdd stages files for commit. +func (env *TestEnv) GitAdd(paths ...string) { + env.T.Helper() + + 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) + } + + for _, path := range paths { + if _, err := worktree.Add(path); err != nil { + env.T.Fatalf("failed to add file %s: %v", path, err) + } + } +} + +// GitCommit creates a commit with all staged files. +func (env *TestEnv) GitCommit(message string) { + env.T.Helper() + + 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(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "E2E Test User", + Email: "e2e-test@example.com", + When: time.Now(), + }, + }) + if err != nil { + env.T.Fatalf("failed to commit: %v", err) + } +} + +// GitCommitWithShadowHooks stages and commits files, running the prepare-commit-msg +// and post-commit hooks like a real workflow. +func (env *TestEnv) GitCommitWithShadowHooks(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 + msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG") + //nolint:gosec // test code, permissions are intentionally standard + 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 + //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed + prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "message") + prepCmd.Dir = env.RepoDir + prepCmd.Env = append(os.Environ(), "ENTIRE_TEST_TTY=1") + if output, err := prepCmd.CombinedOutput(); err != nil { + env.T.Logf("prepare-commit-msg output: %s", output) + } + + // Read the modified message + //nolint:gosec // test code, path is from test setup + 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: "E2E Test User", + Email: "e2e-test@example.com", + When: time.Now(), + }, + }) + if err != nil { + env.T.Fatalf("failed to commit: %v", err) + } + + // Run post-commit hook + //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed + postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit") + postCmd.Dir = env.RepoDir + if output, err := postCmd.CombinedOutput(); err != nil { + env.T.Logf("post-commit output: %s", output) + } +} + +// GitCheckoutNewBranch creates and checks out a new branch. +func (env *TestEnv) GitCheckoutNewBranch(branchName string) { + env.T.Helper() + + //nolint:noctx // test code, no context needed for git checkout + cmd := exec.Command("git", "checkout", "-b", branchName) + cmd.Dir = env.RepoDir + if output, err := cmd.CombinedOutput(); err != nil { + env.T.Fatalf("failed to checkout new branch %s: %v\nOutput: %s", branchName, err, output) + } +} + +// GetHeadHash returns the current HEAD commit hash. +func (env *TestEnv) GetHeadHash() 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) + } + + return head.Hash().String() +} + +// RewindPoint mirrors the rewind --list JSON output. +type RewindPoint struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date time.Time `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` +} + +// GetRewindPoints returns available rewind points using the CLI. +func (env *TestEnv) GetRewindPoints() []RewindPoint { + env.T.Helper() + + //nolint:gosec,noctx // test code, args are static, no context needed + cmd := exec.Command(getTestBinary(), "rewind", "--list") + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + env.T.Fatalf("rewind --list failed: %v\nOutput: %s", err, output) + } + + // Parse JSON output + var jsonPoints []struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date string `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` + } + + if err := json.Unmarshal(output, &jsonPoints); err != nil { + env.T.Fatalf("failed to parse rewind points: %v\nOutput: %s", err, output) + } + + points := make([]RewindPoint, len(jsonPoints)) + for i, jp := range jsonPoints { + //nolint:errcheck // date parsing failure is acceptable, defaults to zero time + date, _ := time.Parse(time.RFC3339, jp.Date) + points[i] = RewindPoint{ + ID: jp.ID, + Message: jp.Message, + MetadataDir: jp.MetadataDir, + Date: date, + IsTaskCheckpoint: jp.IsTaskCheckpoint, + ToolUseID: jp.ToolUseID, + IsLogsOnly: jp.IsLogsOnly, + CondensationID: jp.CondensationID, + } + } + + return points +} + +// Rewind performs a rewind to the specified commit ID using the CLI. +func (env *TestEnv) Rewind(commitID string) error { + env.T.Helper() + + //nolint:gosec,noctx // test code, commitID is from test setup, no context needed + cmd := exec.Command(getTestBinary(), "rewind", "--to", commitID) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.New("rewind failed: " + string(output)) + } + + env.T.Logf("Rewind 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) + } + + refs, err := repo.References() + if err != nil { + env.T.Fatalf("failed to get references: %v", err) + } + + found := false + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + refs.ForEach(func(ref *plumbing.Reference) error { + if ref.Name().Short() == branchName { + found = true + } + return nil + }) + + return found +} + +// 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 +} + +// GetLatestCheckpointIDFromHistory walks backwards from HEAD and returns +// the checkpoint ID from the first commit with an Entire-Checkpoint trailer. +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,gosec // ForEach callback returns error to stop iteration, not a real error + commitIter.ForEach(func(c *object.Commit) error { + // Look for Entire-Checkpoint trailer + for _, line := range strings.Split(c.Message, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Entire-Checkpoint:") { + checkpointID = strings.TrimSpace(strings.TrimPrefix(line, "Entire-Checkpoint:")) + return errors.New("stop iteration") + } + } + return nil + }) + + return checkpointID +} + +// RunCLI runs the entire CLI with the given arguments and returns stdout. +func (env *TestEnv) RunCLI(args ...string) string { + env.T.Helper() + output, err := env.RunCLIWithError(args...) + if err != nil { + env.T.Fatalf("CLI command failed: %v\nArgs: %v\nOutput: %s", err, args, output) + } + return output +} + +// RunCLIWithError runs the entire CLI and returns output and error. +func (env *TestEnv) RunCLIWithError(args ...string) (string, error) { + env.T.Helper() + + //nolint:gosec,noctx // test code, args are from test setup, no context needed + cmd := exec.Command(getTestBinary(), args...) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + return string(output), err +} + +// RunAgent runs the agent with the given prompt and returns the result. +func (env *TestEnv) RunAgent(prompt string) (*AgentResult, error) { + env.T.Helper() + //nolint:wrapcheck // test helper, caller handles error + return env.Agent.RunPrompt(context.Background(), env.RepoDir, prompt) +} + +// RunAgentWithTools runs the agent with specific tools enabled. +func (env *TestEnv) RunAgentWithTools(prompt string, tools []string) (*AgentResult, error) { + env.T.Helper() + //nolint:wrapcheck // test helper, caller handles error + return env.Agent.RunPromptWithTools(context.Background(), env.RepoDir, prompt, tools) +} diff --git a/mise.toml b/mise.toml index 8a713e827..39447cbe7 100644 --- a/mise.toml +++ b/mise.toml @@ -94,3 +94,15 @@ fi echo "Checking staged files for duplication..." git diff --cached --name-only -z --diff-filter=ACM | grep -z '\\.go$' | xargs -0 golangci-lint run --enable-only dupl --new=false --max-issues-per-linter=0 --max-same-issues=0 """ + +[tasks."test:e2e"] +description = "Run E2E tests with real agent calls (requires claude CLI)" +run = "go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:claude"] +description = "Run E2E tests with Claude Code (haiku model)" +run = "E2E_AGENT=claude-code go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:gemini"] +description = "Run E2E tests with Gemini CLI (when implemented)" +run = "E2E_AGENT=gemini-cli go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." From dc8ac334109110a4ff94611275bf40df4077239a Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 16:29:12 +0100 Subject: [PATCH 02/22] review feedback Entire-Checkpoint: 8df6319ef9a6 --- cmd/entire/cli/e2e_test/agent_runner.go | 6 +- cmd/entire/cli/e2e_test/assertions.go | 6 +- cmd/entire/cli/e2e_test/prompts.go | 8 +- .../e2e_test/scenario_agent_commit_test.go | 11 +- .../e2e_test/scenario_basic_workflow_test.go | 6 +- .../cli/e2e_test/scenario_checkpoint_test.go | 10 +- .../cli/e2e_test/scenario_rewind_test.go | 24 +- cmd/entire/cli/e2e_test/setup_test.go | 7 +- cmd/entire/cli/e2e_test/testenv.go | 28 +- cmd/entire/cli/testutil/testutil.go | 283 ++++++++++++++++++ mise.toml | 2 +- 11 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 cmd/entire/cli/testutil/testutil.go diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index 63dc7373c..d32db410d 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -9,7 +9,6 @@ import ( "fmt" "os" "os/exec" - "strings" "time" ) @@ -155,7 +154,10 @@ func (r *ClaudeCodeRunner) RunPromptWithTools(ctx context.Context, workDir strin } if len(tools) > 0 { - args = append(args, "--allowedTools", strings.Join(tools, ",")) + // Claude CLI expects each tool as a separate argument after --allowedTools + // e.g., --allowedTools Edit Read Bash (not --allowedTools "Edit,Read,Bash") + args = append(args, "--allowedTools") + args = append(args, tools...) } // Create context with timeout diff --git a/cmd/entire/cli/e2e_test/assertions.go b/cmd/entire/cli/e2e_test/assertions.go index fa3a522f9..835778029 100644 --- a/cmd/entire/cli/e2e_test/assertions.go +++ b/cmd/entire/cli/e2e_test/assertions.go @@ -114,9 +114,9 @@ func AssertRewindPointCountAtLeast(t *testing.T, env *TestEnv, minimum int) { func AssertCheckpointExists(t *testing.T, env *TestEnv) { t.Helper() - checkpointID := env.GetLatestCheckpointIDFromHistory() - if checkpointID == "" { - t.Error("Expected checkpoint trailer in commit history, but none found") + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + if err != nil || checkpointID == "" { + t.Errorf("Expected checkpoint trailer in commit history, but none found: %v", err) } } diff --git a/cmd/entire/cli/e2e_test/prompts.go b/cmd/entire/cli/e2e_test/prompts.go index 622051499..51c811654 100644 --- a/cmd/entire/cli/e2e_test/prompts.go +++ b/cmd/entire/cli/e2e_test/prompts.go @@ -70,9 +70,11 @@ var PromptAddMultiplyFunction = PromptTemplate{ ExpectedFiles: []string{"calc.go"}, } -// PromptCreateREADME creates a simple README file. -var PromptCreateREADME = PromptTemplate{ - Name: "CreateREADME", +// PromptCreateDocs creates a simple DOCS.md documentation file. +// Note: Uses DOCS.md instead of README.md to avoid conflicts with +// the README.md created by NewFeatureBranchEnv during test setup. +var PromptCreateDocs = PromptTemplate{ + Name: "CreateDocs", Prompt: `Create a file called DOCS.md with exactly this content: # Documentation diff --git a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go index c2fb77d9a..9442de7be 100644 --- a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go +++ b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go @@ -60,8 +60,12 @@ Only run these two commands, nothing else.` env.GitCommitWithShadowHooks("Add calculator", "calc.go") // 7. Final verification - checkpointID := env.GetLatestCheckpointIDFromHistory() - t.Logf("Final checkpoint ID: %s", checkpointID) + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + if err != nil { + t.Logf("No checkpoint ID found: %v", err) + } else { + t.Logf("Final checkpoint ID: %s", checkpointID) + } } // TestE2E_MultipleAgentSessions tests behavior across multiple agent sessions. @@ -108,6 +112,7 @@ func TestE2E_MultipleAgentSessions(t *testing.T) { // Final check: we should have checkpoint IDs in commit history t.Log("Final verification") - checkpointID := env.GetLatestCheckpointIDFromHistory() + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") require.NotEmpty(t, checkpointID, "Should have checkpoint in final commit") } diff --git a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go index 78d9a6646..4feecdde9 100644 --- a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go +++ b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go @@ -42,7 +42,8 @@ func TestE2E_BasicWorkflow(t *testing.T) { // 5. Verify checkpoint was created (trailer in commit) t.Log("Step 5: Verifying checkpoint") - checkpointID := env.GetLatestCheckpointIDFromHistory() + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") assert.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") t.Logf("Checkpoint ID: %s", checkpointID) @@ -82,6 +83,7 @@ func TestE2E_MultipleChanges(t *testing.T) { env.GitCommitWithShadowHooks("Add hello world and calculator", "hello.go", "calc.go") // 5. Verify checkpoint - checkpointID := env.GetLatestCheckpointIDFromHistory() + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") assert.NotEmpty(t, checkpointID) } diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go index 69464d345..0cc78b8e2 100644 --- a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go @@ -31,7 +31,7 @@ func TestE2E_CheckpointMetadata(t *testing.T) { // They should have metadata directories set for i, p := range points { t.Logf("Rewind point %d: ID=%s, MetadataDir=%s, Message=%s", - i, p.ID[:12], p.MetadataDir, p.Message) + i, safeIDPrefix(p.ID), p.MetadataDir, p.Message) } // 3. User commits @@ -39,7 +39,8 @@ func TestE2E_CheckpointMetadata(t *testing.T) { env.GitCommitWithShadowHooks("Add config file", "config.json") // 4. Verify checkpoint trailer added - checkpointID := env.GetLatestCheckpointIDFromHistory() + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint ID in commit history") require.NotEmpty(t, checkpointID, "Should have checkpoint ID in commit") t.Logf("Checkpoint ID: %s", checkpointID) @@ -53,7 +54,7 @@ func TestE2E_CheckpointMetadata(t *testing.T) { // After commit, logs-only points from entire/checkpoints/v1 should exist for i, p := range postPoints { t.Logf("Post-commit point %d: ID=%s, IsLogsOnly=%v, CondensationID=%s", - i, p.ID[:12], p.IsLogsOnly, p.CondensationID) + i, safeIDPrefix(p.ID), p.IsLogsOnly, p.CondensationID) } } @@ -72,7 +73,8 @@ func TestE2E_CheckpointIDFormat(t *testing.T) { env.GitCommitWithShadowHooks("Add hello world", "hello.go") // 3. Verify checkpoint ID format - checkpointID := env.GetLatestCheckpointIDFromHistory() + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint ID in commit history") require.NotEmpty(t, checkpointID) // Checkpoint ID should be 12 hex characters diff --git a/cmd/entire/cli/e2e_test/scenario_rewind_test.go b/cmd/entire/cli/e2e_test/scenario_rewind_test.go index d4d6413b4..4330790ac 100644 --- a/cmd/entire/cli/e2e_test/scenario_rewind_test.go +++ b/cmd/entire/cli/e2e_test/scenario_rewind_test.go @@ -26,7 +26,7 @@ func TestE2E_RewindToCheckpoint(t *testing.T) { points1 := env.GetRewindPoints() require.GreaterOrEqual(t, len(points1), 1) firstPointID := points1[0].ID - t.Logf("First checkpoint: %s", firstPointID[:12]) + t.Logf("First checkpoint: %s", safeIDPrefix(firstPointID)) // Save original content originalContent := env.ReadFile("hello.go") @@ -95,16 +95,28 @@ func TestE2E_RewindAfterCommit(t *testing.T) { t.Logf("Found %d rewind points", len(points)) for i, p := range points { t.Logf(" Point %d: %s (logs_only=%v, condensation_id=%s)", - i, p.ID[:12], p.IsLogsOnly, p.CondensationID) + i, safeIDPrefix(p.ID), p.IsLogsOnly, p.CondensationID) } // 5. Rewind to pre-commit checkpoint - t.Log("Step 5: Rewinding to pre-commit checkpoint") + // After commit, the pre-commit checkpoint becomes logs-only because the shadow branch + // is condensed. Rewinding to a logs-only point should fail with a clear error. + t.Log("Step 5: Attempting rewind to pre-commit (logs-only) checkpoint") err = env.Rewind(preCommitPointID) - // Note: After commit, rewinding to a pre-commit checkpoint may only restore logs - // depending on the checkpoint's state + + // Verify the current file state regardless of rewind result + currentContent := env.ReadFile("hello.go") + if err != nil { - t.Logf("Rewind result: %v (may be expected for logs-only points)", err) + // Rewind failed as expected for logs-only checkpoint + t.Logf("Rewind to logs-only point failed as expected: %v", err) + // File should still have the modifications since rewind failed + assert.Contains(t, currentContent, "E2E Test", + "File should still have modifications after logs-only rewind failure") + } else { + // If rewind succeeded, the file might have been restored + // This could happen if the rewind point wasn't actually logs-only + t.Log("Rewind succeeded - checkpoint may not have been logs-only") } } diff --git a/cmd/entire/cli/e2e_test/setup_test.go b/cmd/entire/cli/e2e_test/setup_test.go index 0c26243cd..442c7b1a8 100644 --- a/cmd/entire/cli/e2e_test/setup_test.go +++ b/cmd/entire/cli/e2e_test/setup_test.go @@ -58,7 +58,12 @@ func TestMain(m *testing.M) { os.Exit(1) } - // Add binary to PATH so hooks can find it + // Add binary to PATH so hooks can find it. + // This is safe because: + // 1. TestMain runs once before any tests (no parallel conflict with os.Setenv) + // 2. Each test package gets its own TestMain execution + // 3. PATH is restored in the same TestMain after m.Run() completes + // 4. The binary path is unique per test run (temp dir) origPath := os.Getenv("PATH") os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+origPath) diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 8668d7b97..c57019e70 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -274,7 +274,11 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "message") prepCmd.Dir = env.RepoDir - prepCmd.Env = append(os.Environ(), "ENTIRE_TEST_TTY=1") + prepCmd.Env = append(os.Environ(), + "ENTIRE_TEST_TTY=1", + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) } @@ -312,6 +316,10 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit") postCmd.Dir = env.RepoDir + postCmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) } @@ -469,7 +477,8 @@ func (env *TestEnv) GetCommitMessage(hash string) string { // GetLatestCheckpointIDFromHistory walks backwards from HEAD and returns // the checkpoint ID from the first commit with an Entire-Checkpoint trailer. -func (env *TestEnv) GetLatestCheckpointIDFromHistory() string { +// Returns an error if no checkpoint trailer is found in any commit. +func (env *TestEnv) GetLatestCheckpointIDFromHistory() (string, error) { env.T.Helper() repo, err := git.PlainOpen(env.RepoDir) @@ -501,7 +510,20 @@ func (env *TestEnv) GetLatestCheckpointIDFromHistory() string { return nil }) - return checkpointID + if checkpointID == "" { + return "", errors.New("no commit with Entire-Checkpoint trailer found in history") + } + + return checkpointID, nil +} + +// safeIDPrefix returns first 12 chars of ID or the full ID if shorter. +// Use this when logging checkpoint IDs to avoid index out of bounds panic. +func safeIDPrefix(id string) string { + if len(id) >= 12 { + return id[:12] + } + return id } // RunCLI runs the entire CLI with the given arguments and returns stdout. diff --git a/cmd/entire/cli/testutil/testutil.go b/cmd/entire/cli/testutil/testutil.go new file mode 100644 index 000000000..bdffb8f13 --- /dev/null +++ b/cmd/entire/cli/testutil/testutil.go @@ -0,0 +1,283 @@ +// Package testutil provides shared test utilities for both integration and e2e tests. +// This package has no build tags, making it usable by all test packages. +package testutil + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/format/config" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// RewindPoint mirrors the rewind --list JSON output. +type RewindPoint struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date time.Time `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` +} + +// InitRepo initializes a git repository in the given directory with test user config. +func InitRepo(t *testing.T, repoDir string) { + t.Helper() + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for commits + cfg, err := repo.Config() + if err != nil { + t.Fatalf("failed to get repo config: %v", err) + } + cfg.User.Name = "Test User" + cfg.User.Email = "test@example.com" + + // Disable GPG signing for test commits + if cfg.Raw == nil { + cfg.Raw = config.New() + } + cfg.Raw.Section("commit").SetOption("gpgsign", "false") + + if err := repo.SetConfig(cfg); err != nil { + t.Fatalf("failed to set repo config: %v", err) + } +} + +// WriteFile creates a file with the given content in the repo directory. +// It creates parent directories as needed. +func WriteFile(t *testing.T, repoDir, path, content string) { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + + // Create parent directories + dir := filepath.Dir(fullPath) + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create directory %s: %v", dir, err) + } + + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } +} + +// ReadFile reads a file from the repo directory. +func ReadFile(t *testing.T, repoDir, path string) string { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("failed to read file %s: %v", path, err) + } + return string(data) +} + +// TryReadFile reads a file from the repo directory, returning empty string if not found. +func TryReadFile(t *testing.T, repoDir, path string) string { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + return string(data) +} + +// FileExists checks if a file exists in the repo directory. +func FileExists(repoDir, path string) bool { + fullPath := filepath.Join(repoDir, path) + _, err := os.Stat(fullPath) + return err == nil +} + +// GitAdd stages files for commit. +func GitAdd(t *testing.T, repoDir string, paths ...string) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + for _, path := range paths { + if _, err := worktree.Add(path); err != nil { + t.Fatalf("failed to add file %s: %v", path, err) + } + } +} + +// GitCommit creates a commit with all staged files. +func GitCommit(t *testing.T, repoDir, message string) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + _, err = worktree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } +} + +// GitCheckoutNewBranch creates and checks out a new branch. +// Uses git CLI to work around go-git v5 bug with checkout deleting untracked files. +func GitCheckoutNewBranch(t *testing.T, repoDir, branchName string) { + t.Helper() + + //nolint:noctx // test code, no context needed for git checkout + cmd := exec.Command("git", "checkout", "-b", branchName) + cmd.Dir = repoDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to checkout new branch %s: %v\nOutput: %s", branchName, err, output) + } +} + +// GetHeadHash returns the current HEAD commit hash. +func GetHeadHash(t *testing.T, repoDir string) string { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + return head.Hash().String() +} + +// BranchExists checks if a branch exists in the repository. +func BranchExists(t *testing.T, repoDir, branchName string) bool { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + refs, err := repo.References() + if err != nil { + t.Fatalf("failed to get references: %v", err) + } + + found := false + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + refs.ForEach(func(ref *plumbing.Reference) error { + if ref.Name().Short() == branchName { + found = true + } + return nil + }) + + return found +} + +// GetCommitMessage returns the commit message for the given commit hash. +func GetCommitMessage(t *testing.T, repoDir, hash string) string { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + commitHash := plumbing.NewHash(hash) + commit, err := repo.CommitObject(commitHash) + if err != nil { + t.Fatalf("failed to get commit %s: %v", hash, err) + } + + return commit.Message +} + +// GetLatestCheckpointIDFromHistory walks backwards from HEAD and returns +// the checkpoint ID from the first commit with an Entire-Checkpoint trailer. +// Returns an error if no checkpoint trailer is found in any commit. +func GetLatestCheckpointIDFromHistory(t *testing.T, repoDir string) (string, error) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("failed to iterate commits: %v", err) + } + + var checkpointID string + //nolint:errcheck,gosec // ForEach callback returns error to stop iteration + commitIter.ForEach(func(c *object.Commit) error { + // Look for Entire-Checkpoint trailer + for line := range strings.SplitSeq(c.Message, "\n") { + line = strings.TrimSpace(line) + if value, found := strings.CutPrefix(line, "Entire-Checkpoint:"); found { + checkpointID = strings.TrimSpace(value) + return errors.New("stop iteration") + } + } + return nil + }) + + if checkpointID == "" { + return "", errors.New("no commit with Entire-Checkpoint trailer found in history") + } + + return checkpointID, nil +} + +// SafeIDPrefix returns first 12 chars of ID or the full ID if shorter. +// Use this when logging checkpoint IDs to avoid index out of bounds panic. +func SafeIDPrefix(id string) string { + if len(id) >= 12 { + return id[:12] + } + return id +} diff --git a/mise.toml b/mise.toml index 39447cbe7..288a69896 100644 --- a/mise.toml +++ b/mise.toml @@ -104,5 +104,5 @@ description = "Run E2E tests with Claude Code (haiku model)" run = "E2E_AGENT=claude-code go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." [tasks."test:e2e:gemini"] -description = "Run E2E tests with Gemini CLI (when implemented)" +description = "Run E2E tests with Gemini CLI (runner not yet implemented - will skip)" run = "E2E_AGENT=gemini-cli go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." From edc1580703bb8a759f4eed993b83983e87519e4c Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 16:38:28 +0100 Subject: [PATCH 03/22] copilot review comments addressed Entire-Checkpoint: 7744ff0ccd17 --- cmd/entire/cli/e2e_test/agent_runner.go | 6 ++-- cmd/entire/cli/e2e_test/testenv.go | 38 ++----------------------- mise.toml | 7 +++-- 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index d32db410d..97b681023 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -122,8 +122,10 @@ func (r *ClaudeCodeRunner) Name() string { return AgentNameClaudeCode } -// IsAvailable checks if Claude CLI is installed and working. -// Note: Claude Code uses OAuth authentication (via `claude login`), not ANTHROPIC_API_KEY. +// IsAvailable checks if Claude CLI is installed and responds to --version. +// Note: This does NOT verify authentication status. Claude Code uses OAuth +// authentication (via `claude login`), not ANTHROPIC_API_KEY. If the CLI is +// installed but not logged in, tests will fail at RunPrompt time. func (r *ClaudeCodeRunner) IsAvailable() (bool, error) { // Check if claude CLI is in PATH if _, err := exec.LookPath("claude"); err != nil { diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index c57019e70..85837c257 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -67,12 +67,13 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { } // RunEntireEnable runs `entire enable` to set up the project with hooks. +// Uses the configured defaultAgent (from E2E_AGENT env var or "claude-code"). func (env *TestEnv) RunEntireEnable(strategyName string) { env.T.Helper() args := []string{ "enable", - "--agent", "claude-code", + "--agent", defaultAgent, "--strategy", strategyName, "--telemetry=false", "--force", // Force reinstall hooks in case they exist @@ -117,41 +118,6 @@ func (env *TestEnv) InitRepo() { } } -// InitEntire initializes the .entire directory with the specified strategy. -func (env *TestEnv) InitEntire(strategyName string) { - env.T.Helper() - - // Create .entire directory structure - entireDir := filepath.Join(env.RepoDir, ".entire") - //nolint:gosec // test code, permissions are intentionally standard - if err := os.MkdirAll(entireDir, 0o755); err != nil { - env.T.Fatalf("failed to create .entire directory: %v", err) - } - - // Create tmp directory - tmpDir := filepath.Join(entireDir, "tmp") - //nolint:gosec // test code, permissions are intentionally standard - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - env.T.Fatalf("failed to create .entire/tmp directory: %v", err) - } - - // Write settings.json - settings := map[string]any{ - "strategy": strategyName, - "local_dev": true, // Use go run for hooks in tests - } - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - env.T.Fatalf("failed to marshal settings: %v", err) - } - data = append(data, '\n') - settingsPath := filepath.Join(entireDir, "settings.json") - //nolint:gosec // test code, permissions are intentionally standard - if err := os.WriteFile(settingsPath, data, 0o644); err != nil { - env.T.Fatalf("failed to write settings.json: %v", err) - } -} - // WriteFile creates a file with the given content in the test repo. func (env *TestEnv) WriteFile(path, content string) { env.T.Helper() diff --git a/mise.toml b/mise.toml index 288a69896..9d9a7e7d0 100644 --- a/mise.toml +++ b/mise.toml @@ -97,12 +97,13 @@ git diff --cached --name-only -z --diff-filter=ACM | grep -z '\\.go$' | xargs -0 [tasks."test:e2e"] description = "Run E2E tests with real agent calls (requires claude CLI)" -run = "go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." +# -count=1 disables test caching since E2E tests call real external agents +run = "go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." [tasks."test:e2e:claude"] description = "Run E2E tests with Claude Code (haiku model)" -run = "E2E_AGENT=claude-code go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." +run = "E2E_AGENT=claude-code go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." [tasks."test:e2e:gemini"] description = "Run E2E tests with Gemini CLI (runner not yet implemented - will skip)" -run = "E2E_AGENT=gemini-cli go test -tags=e2e -timeout=30m -v ./cmd/entire/cli/e2e_test/..." +run = "E2E_AGENT=gemini-cli go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." From 71644aa98cec9588b0ff45cbe9b37e3c1c950085 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 15:51:52 +0100 Subject: [PATCH 04/22] 1:1 checkpoint to commit Entire-Checkpoint: 49280afe3e31 --- CLAUDE.md | 7 +- cmd/entire/cli/checkpoint/checkpoint.go | 35 + cmd/entire/cli/checkpoint/committed.go | 159 ++++ .../cli/checkpoint/committed_update_test.go | 419 +++++++++ cmd/entire/cli/doctor.go | 2 +- cmd/entire/cli/doctor_test.go | 40 - cmd/entire/cli/hooks.go | 2 +- cmd/entire/cli/hooks_claudecode_handlers.go | 9 +- .../last_checkpoint_id_test.go | 206 ++-- .../phase_transitions_test.go | 26 +- cmd/entire/cli/phase_wiring_test.go | 22 - cmd/entire/cli/reset.go | 2 +- cmd/entire/cli/session/phase.go | 73 +- cmd/entire/cli/session/phase_test.go | 76 +- cmd/entire/cli/session/state.go | 13 +- cmd/entire/cli/strategy/auto_commit.go | 14 + .../strategy/manual_commit_condensation.go | 2 +- cmd/entire/cli/strategy/manual_commit_git.go | 7 +- .../cli/strategy/manual_commit_hooks.go | 602 ++++++------ .../cli/strategy/manual_commit_session.go | 8 + cmd/entire/cli/strategy/manual_commit_test.go | 1 - .../cli/strategy/mid_turn_commit_test.go | 60 -- .../cli/strategy/phase_postcommit_test.go | 882 ++++++++---------- .../strategy/phase_prepare_commit_msg_test.go | 204 +--- cmd/entire/cli/strategy/phase_wiring_test.go | 31 +- cmd/entire/cli/strategy/strategy.go | 8 +- 26 files changed, 1437 insertions(+), 1473 deletions(-) create mode 100644 cmd/entire/cli/checkpoint/committed_update_test.go diff --git a/CLAUDE.md b/CLAUDE.md index eea040e9e..ec65318c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,7 +327,7 @@ All strategies implement: Sessions track their lifecycle through phases managed by a state machine in `session/phase.go`: -**Phases:** `ACTIVE`, `ACTIVE_COMMITTED`, `IDLE`, `ENDED` +**Phases:** `ACTIVE`, `IDLE`, `ENDED` **Events:** - `TurnStart` - Agent begins a turn (UserPromptSubmit hook) @@ -339,12 +339,11 @@ Sessions track their lifecycle through phases managed by a state machine in `ses **Key transitions:** - `IDLE + TurnStart → ACTIVE` - Agent starts working - `ACTIVE + TurnEnd → IDLE` - Agent finishes turn -- `ACTIVE + GitCommit → ACTIVE_COMMITTED` - User commits while agent is working (condensation deferred) -- `ACTIVE_COMMITTED + TurnEnd → IDLE` - Agent finishes after commit (condense now) +- `ACTIVE + GitCommit → ACTIVE` - User commits while agent is working (condense immediately) - `IDLE + GitCommit → IDLE` - User commits between turns (condense immediately) - `ENDED + GitCommit → ENDED` - Post-session commit (condense if files touched) -The state machine emits **actions** (e.g., `ActionCondense`, `ActionMigrateShadowBranch`, `ActionDeferCondensation`) that hook handlers dispatch to strategy-specific implementations. +The state machine emits **actions** (e.g., `ActionCondense`, `ActionUpdateLastInteraction`) that hook handlers dispatch to strategy-specific implementations. #### Metadata Structure diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index e79fe83bf..f6457d6b8 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -101,6 +101,12 @@ type Store interface { // ListCommitted lists all committed checkpoints. ListCommitted(ctx context.Context) ([]CommittedInfo, error) + + // UpdateCommitted replaces the transcript, prompts, and context for an existing + // committed checkpoint. Used at stop time to finalize checkpoints with the full + // session transcript (prompt to stop event). + // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. + UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error } // WriteTemporaryResult contains the result of writing a temporary checkpoint. @@ -255,6 +261,9 @@ type WriteCommittedOptions struct { // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") Agent agent.AgentType + // TurnID correlates checkpoints from the same agent turn. + TurnID string + // Transcript position at checkpoint start - tracks what was added during this checkpoint TranscriptIdentifierAtStart string // Last identifier when checkpoint started (UUID for Claude, message ID for Gemini) CheckpointTranscriptStart int // Transcript line offset at start of this checkpoint's data @@ -283,6 +292,27 @@ type WriteCommittedOptions struct { SessionTranscriptPath string } +// UpdateCommittedOptions contains options for updating an existing committed checkpoint. +// Uses replace semantics: the transcript, prompts, and context are fully replaced, +// not appended. At stop time we have the complete session transcript and want every +// checkpoint to contain it identically. +type UpdateCommittedOptions struct { + // CheckpointID identifies the checkpoint to update + CheckpointID id.CheckpointID + + // SessionID identifies which session slot to update within the checkpoint + SessionID string + + // Transcript is the full session transcript (replaces existing) + Transcript []byte + + // Prompts contains all user prompts (replaces existing) + Prompts []string + + // Context is the updated context.md content (replaces existing) + Context []byte +} + // CommittedInfo contains summary information about a committed checkpoint. type CommittedInfo struct { // CheckpointID is the stable 12-hex-char identifier @@ -345,6 +375,11 @@ type CommittedMetadata struct { // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") Agent agent.AgentType `json:"agent,omitempty"` + // TurnID correlates checkpoints from the same agent turn. + // When a turn's work spans multiple commits, each gets its own checkpoint + // but they share the same TurnID for future aggregation/deduplication. + TurnID string `json:"turn_id,omitempty"` + // Task checkpoint fields (only populated for task checkpoints) IsTask bool `json:"is_task,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"` diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 67cf6947c..d58882d91 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -339,6 +339,7 @@ func (s *GitStore) writeSessionToSubdirectory(opts WriteCommittedOptions, sessio CheckpointsCount: opts.CheckpointsCount, FilesTouched: opts.FilesTouched, Agent: opts.Agent, + TurnID: opts.TurnID, IsTask: opts.IsTask, ToolUseID: opts.ToolUseID, TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart, @@ -1008,6 +1009,164 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint 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 { + if opts.CheckpointID.IsEmpty() { + return errors.New("invalid update options: checkpoint ID is required") + } + + // Ensure sessions branch exists + if err := s.ensureSessionsBranch(); err != nil { + return fmt.Errorf("failed to ensure sessions branch: %w", err) + } + + // Get current branch tip and flatten tree + ref, entries, err := s.getSessionsBranchEntries() + if err != nil { + return err + } + + // Read root CheckpointSummary to find the session slot + basePath := opts.CheckpointID.Path() + "/" + 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) + if len(opts.Transcript) > 0 { + if err := s.replaceTranscript(opts.Transcript, sessionPath, entries); err != nil { + return fmt.Errorf("failed to replace transcript: %w", err) + } + } + + // Replace prompts + if len(opts.Prompts) > 0 { + promptContent := strings.Join(opts.Prompts, "\n\n---\n\n") + 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, + } + } + + // Replace context + if len(opts.Context) > 0 { + contextBlob, err := CreateBlobFromContent(s.repo, opts.Context) + if err != nil { + return fmt.Errorf("failed to create context blob: %w", err) + } + entries[sessionPath+paths.ContextFileName] = object.TreeEntry{ + Name: sessionPath + paths.ContextFileName, + Mode: filemode.Regular, + Hash: contextBlob, + } + } + + // Build and commit + newTreeHash, err := BuildTreeFromEntries(s.repo, entries) + if err != nil { + return err + } + + authorName, authorEmail := getGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Finalize transcript for checkpoint %s", opts.CheckpointID) + newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), 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. +func (s *GitStore) replaceTranscript(transcript []byte, sessionPath string, entries map[string]object.TreeEntry) error { + // 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) + } + } + + // Write new transcript blob + blobHash, err := CreateBlobFromContent(s.repo, transcript) + if err != nil { + return fmt.Errorf("failed to create transcript blob: %w", err) + } + entries[transcriptBase] = object.TreeEntry{ + Name: transcriptBase, + Mode: filemode.Regular, + Hash: blobHash, + } + + // Update content hash + contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(transcript)) + hashBlob, err := CreateBlobFromContent(s.repo, []byte(contentHash)) + if err != nil { + return fmt.Errorf("failed to create content hash blob: %w", err) + } + hashPath := sessionPath + paths.ContentHashFileName + entries[hashPath] = object.TreeEntry{ + Name: hashPath, + Mode: filemode.Regular, + Hash: hashBlob, + } + + return nil +} + // ensureSessionsBranch ensures the entire/checkpoints/v1 branch exists. func (s *GitStore) ensureSessionsBranch() error { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) diff --git a/cmd/entire/cli/checkpoint/committed_update_test.go b/cmd/entire/cli/checkpoint/committed_update_test.go new file mode 100644 index 000000000..6470fd270 --- /dev/null +++ b/cmd/entire/cli/checkpoint/committed_update_test.go @@ -0,0 +1,419 @@ +package checkpoint + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// setupRepoForUpdate creates a repo with an initial commit and writes a committed checkpoint. +func setupRepoForUpdate(t *testing.T) (*git.Repository, *GitStore, id.CheckpointID) { + t.Helper() + + 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) + cpID := id.MustCheckpointID("a1b2c3d4e5f6") + + err = store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Strategy: "manual-commit", + Transcript: []byte("provisional transcript line 1\n"), + Prompts: []string{"initial prompt"}, + Context: []byte("initial context"), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + if err != nil { + t.Fatalf("WriteCommitted() error = %v", err) + } + + return repo, store, cpID +} + +func TestUpdateCommitted_ReplacesTranscript(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + // Update with full transcript (replace semantics) + fullTranscript := []byte("full transcript line 1\nfull transcript line 2\nfull transcript line 3\n") + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: fullTranscript, + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + // Read back and verify transcript was replaced (not appended) + content, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + + if string(content.Transcript) != string(fullTranscript) { + t.Errorf("transcript mismatch\ngot: %q\nwant: %q", string(content.Transcript), string(fullTranscript)) + } +} + +func TestUpdateCommitted_ReplacesPrompts(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Prompts: []string{"prompt 1", "prompt 2", "prompt 3"}, + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + content, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + + expected := "prompt 1\n\n---\n\nprompt 2\n\n---\n\nprompt 3" + if content.Prompts != expected { + t.Errorf("prompts mismatch\ngot: %q\nwant: %q", content.Prompts, expected) + } +} + +func TestUpdateCommitted_ReplacesContext(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Context: []byte("updated context with full session info"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + content, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + + if content.Context != "updated context with full session info" { + t.Errorf("context mismatch\ngot: %q\nwant: %q", content.Context, "updated context with full session info") + } +} + +func TestUpdateCommitted_ReplacesAllFieldsTogether(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + fullTranscript := []byte("complete transcript\n") + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: fullTranscript, + Prompts: []string{"final prompt"}, + Context: []byte("final context"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + content, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + + if string(content.Transcript) != string(fullTranscript) { + t.Errorf("transcript mismatch\ngot: %q\nwant: %q", string(content.Transcript), string(fullTranscript)) + } + if content.Prompts != "final prompt" { + t.Errorf("prompts mismatch\ngot: %q\nwant: %q", content.Prompts, "final prompt") + } + if content.Context != "final context" { + t.Errorf("context mismatch\ngot: %q\nwant: %q", content.Context, "final context") + } +} + +func TestUpdateCommitted_NonexistentCheckpoint(t *testing.T) { + t.Parallel() + _, store, _ := setupRepoForUpdate(t) + + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: id.MustCheckpointID("deadbeef1234"), + SessionID: "session-001", + Transcript: []byte("should fail"), + }) + if err == nil { + t.Fatal("expected error for nonexistent checkpoint, got nil") + } +} + +func TestUpdateCommitted_PreservesMetadata(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + // Read metadata before update + contentBefore, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() before error = %v", err) + } + + // Update only transcript + err = store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: []byte("updated transcript\n"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + // Read metadata after update - should be unchanged + contentAfter, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() after error = %v", err) + } + + if contentAfter.Metadata.SessionID != contentBefore.Metadata.SessionID { + t.Errorf("session ID changed: %q -> %q", contentBefore.Metadata.SessionID, contentAfter.Metadata.SessionID) + } + if contentAfter.Metadata.Strategy != contentBefore.Metadata.Strategy { + t.Errorf("strategy changed: %q -> %q", contentBefore.Metadata.Strategy, contentAfter.Metadata.Strategy) + } +} + +func TestUpdateCommitted_MultipleCheckpoints(t *testing.T) { + t.Parallel() + _, store, cpID1 := setupRepoForUpdate(t) + + // Write a second checkpoint + cpID2 := id.MustCheckpointID("b2c3d4e5f6a1") + err := store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: cpID2, + SessionID: "session-001", + Strategy: "manual-commit", + Transcript: []byte("provisional cp2\n"), + Prompts: []string{"cp2 prompt"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + if err != nil { + t.Fatalf("WriteCommitted(cp2) error = %v", err) + } + + fullTranscript := []byte("complete full transcript\n") + + // Update both checkpoints with the same full transcript + for _, cpID := range []id.CheckpointID{cpID1, cpID2} { + err = store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: fullTranscript, + Prompts: []string{"final prompt 1", "final prompt 2"}, + Context: []byte("final context"), + }) + if err != nil { + t.Fatalf("UpdateCommitted(%s) error = %v", cpID, err) + } + } + + // Verify both have the full transcript + for _, cpID := range []id.CheckpointID{cpID1, cpID2} { + content, readErr := store.ReadSessionContent(context.Background(), cpID, 0) + if readErr != nil { + t.Fatalf("ReadSessionContent(%s) error = %v", cpID, readErr) + } + if string(content.Transcript) != string(fullTranscript) { + t.Errorf("checkpoint %s: transcript mismatch\ngot: %q\nwant: %q", cpID, string(content.Transcript), string(fullTranscript)) + } + } +} + +func TestUpdateCommitted_UpdatesContentHash(t *testing.T) { + t.Parallel() + repo, store, cpID := setupRepoForUpdate(t) + + // Update transcript + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: []byte("new full transcript content\n"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + // Verify content_hash.txt was updated + ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("failed to get 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) + } + + hashPath := cpID.Path() + "/0/" + paths.ContentHashFileName + hashFile, err := tree.File(hashPath) + if err != nil { + t.Fatalf("content_hash.txt not found at %s: %v", hashPath, err) + } + hashContent, err := hashFile.Contents() + if err != nil { + t.Fatalf("failed to read content_hash.txt: %v", err) + } + + // Hash should be for the new content, not the old + if hashContent == "" || !isValidContentHash(hashContent) { + t.Errorf("invalid content hash: %q", hashContent) + } +} + +func TestUpdateCommitted_EmptyCheckpointID(t *testing.T) { + t.Parallel() + _, store, _ := setupRepoForUpdate(t) + + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + SessionID: "session-001", + Transcript: []byte("should fail"), + }) + if err == nil { + t.Fatal("expected error for empty checkpoint ID, got nil") + } +} + +func TestUpdateCommitted_FallsBackToLatestSession(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + // Update with wrong session ID — should fall back to latest (index 0) + fullTranscript := []byte("updated via fallback\n") + err := store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "nonexistent-session", + Transcript: fullTranscript, + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + // Verify transcript was updated on the latest (and only) session + content, err := store.ReadSessionContent(context.Background(), cpID, 0) + if err != nil { + t.Fatalf("ReadSessionContent() error = %v", err) + } + if string(content.Transcript) != string(fullTranscript) { + t.Errorf("transcript mismatch\ngot: %q\nwant: %q", string(content.Transcript), string(fullTranscript)) + } +} + +func TestUpdateCommitted_SummaryPreserved(t *testing.T) { + t.Parallel() + _, store, cpID := setupRepoForUpdate(t) + + // Verify the root-level CheckpointSummary is preserved after update + summaryBefore, err := store.ReadCommitted(context.Background(), cpID) + if err != nil { + t.Fatalf("ReadCommitted() before error = %v", err) + } + + err = store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: []byte("updated\n"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + summaryAfter, err := store.ReadCommitted(context.Background(), cpID) + if err != nil { + t.Fatalf("ReadCommitted() after error = %v", err) + } + + if summaryAfter.CheckpointID != summaryBefore.CheckpointID { + t.Errorf("checkpoint ID changed in summary") + } + if len(summaryAfter.Sessions) != len(summaryBefore.Sessions) { + t.Errorf("sessions count changed: %d -> %d", len(summaryBefore.Sessions), len(summaryAfter.Sessions)) + } +} + +func isValidContentHash(hash string) bool { + return len(hash) > 10 && hash[:7] == "sha256:" +} + +// Verify JSON serialization of the new fields on State +func TestState_TurnCheckpointIDs_JSON(t *testing.T) { + t.Parallel() + + type stateSnippet struct { + TurnCheckpointIDs []string `json:"turn_checkpoint_ids,omitempty"` + } + + // Test round-trip + original := stateSnippet{ + TurnCheckpointIDs: []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"}, + } + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var decoded stateSnippet + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if len(decoded.TurnCheckpointIDs) != 2 { + t.Errorf("expected 2 IDs, got %d", len(decoded.TurnCheckpointIDs)) + } + + // Test nil serialization (omitempty) + empty := stateSnippet{} + data, err = json.Marshal(empty) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + if string(data) != "{}" { + t.Errorf("expected empty JSON, got %s", string(data)) + } +} diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index f4103f706..1b5a0a940 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -28,7 +28,7 @@ func newDoctorCmd() *cobra.Command { Long: `Scan for stuck or problematic sessions and offer to fix them. A session is considered stuck if: - - It is in ACTIVE or ACTIVE_COMMITTED phase with no interaction for over 1 hour + - It is in ACTIVE phase with no interaction for over 1 hour - It is in ENDED phase with uncondensed checkpoint data on a shadow branch For each stuck session, you can choose to: diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index ce5f9599f..152c6dc76 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -94,7 +94,6 @@ func TestClassifySession_ActiveStale_OldInteractionTime(t *testing.T) { assert.Equal(t, 2, result.FilesTouchedCount) } -//nolint:dupl // Tests distinct phase (ACTIVE vs ACTIVE_COMMITTED), collapsing harms readability func TestClassifySession_ActiveRecent_Healthy(t *testing.T) { dir := setupGitRepoForPhaseTest(t) repo, err := git.PlainOpen(dir) @@ -113,45 +112,6 @@ func TestClassifySession_ActiveRecent_Healthy(t *testing.T) { assert.Nil(t, result, "active session with recent interaction should be healthy") } -func TestClassifySession_ActiveCommittedStale(t *testing.T) { - dir := setupGitRepoForPhaseTest(t) - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - twoHoursAgo := time.Now().Add(-2 * time.Hour) - state := &strategy.SessionState{ - SessionID: "test-ac-stale", - BaseCommit: testBaseCommit, - Phase: session.PhaseActiveCommitted, - StepCount: 5, - LastInteractionTime: &twoHoursAgo, - } - - result := classifySession(state, repo, time.Now()) - - require.NotNil(t, result, "ACTIVE_COMMITTED session with stale interaction should be stuck") - assert.Contains(t, result.Reason, "active, last interaction") -} - -//nolint:dupl // Tests distinct phase (ACTIVE_COMMITTED vs ACTIVE), collapsing harms readability -func TestClassifySession_ActiveCommittedRecent_Healthy(t *testing.T) { - dir := setupGitRepoForPhaseTest(t) - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - recentTime := time.Now().Add(-30 * time.Minute) - state := &strategy.SessionState{ - SessionID: "test-ac-healthy", - BaseCommit: testBaseCommit, - Phase: session.PhaseActiveCommitted, - StepCount: 5, - LastInteractionTime: &recentTime, - } - - result := classifySession(state, repo, time.Now()) - assert.Nil(t, result, "ACTIVE_COMMITTED session with recent interaction should be healthy") -} - func TestClassifySession_EndedWithUncondensedData(t *testing.T) { dir := setupGitRepoForPhaseTest(t) repo, err := git.PlainOpen(dir) diff --git a/cmd/entire/cli/hooks.go b/cmd/entire/cli/hooks.go index 363b528f1..005ae8c5c 100644 --- a/cmd/entire/cli/hooks.go +++ b/cmd/entire/cli/hooks.go @@ -280,7 +280,7 @@ func handleSessionStartCommon() error { // Fire EventSessionStart for the current session (if state exists). // This handles ENDED → IDLE (re-entering a session). - // TODO(ENT-221): dispatch ActionWarnStaleSession for ACTIVE/ACTIVE_COMMITTED sessions. + // TODO(ENT-221): dispatch ActionWarnStaleSession for ACTIVE sessions. if state, loadErr := strategy.LoadSessionState(input.SessionID); loadErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to load session state on start: %v\n", loadErr) } else if state != nil { diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index 450821f2e..16d52327a 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -383,8 +383,7 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba } // Fire EventTurnEnd to transition session phase (all strategies). - // This moves ACTIVE → IDLE or ACTIVE_COMMITTED → IDLE. - // For ACTIVE_COMMITTED → IDLE, HandleTurnEnd dispatches ActionCondense. + // This moves ACTIVE → IDLE. transitionSessionTurnEnd(sessionID) // Clean up pre-prompt state (CLI responsibility) @@ -733,8 +732,8 @@ func handleClaudeCodeSessionEnd() error { } // transitionSessionTurnEnd fires EventTurnEnd to move the session from -// ACTIVE → IDLE (or ACTIVE_COMMITTED → IDLE). Best-effort: logs warnings -// on failure rather than returning errors. +// ACTIVE → IDLE. Best-effort: logs warnings on failure rather than +// returning errors. func transitionSessionTurnEnd(sessionID string) { turnState, loadErr := strategy.LoadSessionState(sessionID) if loadErr != nil { @@ -746,7 +745,7 @@ func transitionSessionTurnEnd(sessionID string) { } remaining := strategy.TransitionAndLog(turnState, session.EventTurnEnd, session.TransitionContext{}) - // Dispatch strategy-specific actions (e.g., ActionCondense for ACTIVE_COMMITTED → IDLE) + // Dispatch strategy-specific actions if any remain after common handling if len(remaining) > 0 { strat := GetStrategy() if handler, ok := strat.(strategy.TurnEndHandler); ok { diff --git a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go index f30e74209..22ca55856 100644 --- a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go +++ b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go @@ -11,51 +11,44 @@ import ( "github.com/entireio/cli/cmd/entire/cli/strategy" ) -// TestShadowStrategy_LastCheckpointID_ReusedAcrossCommits tests that when a user -// commits Claude's work across multiple commits without entering new prompts, -// all commits reference the same checkpoint ID. +// TestShadowStrategy_OneCheckpointPerCommit tests the 1:1 checkpoint model: +// each commit gets its own unique checkpoint ID. When a session touches multiple +// files and the user splits them across commits (IDLE session), only the first +// commit gets a checkpoint trailer (via condensation). The second commit has no +// associated session data to condense. // // Flow: -// 1. Claude session edits files A and B -// 2. User commits file A (with hooks) → condensation, LastCheckpointID saved -// 3. User commits file B (with hooks) → no new content, reuses LastCheckpointID -// 4. Both commits should have the same Entire-Checkpoint trailer -func TestShadowStrategy_LastCheckpointID_ReusedAcrossCommits(t *testing.T) { +// 1. Claude session edits files A and B, then stops (IDLE) +// 2. User commits file A → condensation → unique checkpoint ID #1 +// 3. User commits file B → no session content to condense → no trailer +func TestShadowStrategy_OneCheckpointPerCommit(t *testing.T) { t.Parallel() - // Only test manual-commit strategy since this is shadow-specific behavior env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - // Create a session session := env.NewSession() - // Simulate user prompt submit (initializes session) if err := env.SimulateUserPromptSubmit(session.ID); err != nil { t.Fatalf("SimulateUserPromptSubmit failed: %v", err) } - // Create two files as if Claude wrote them env.WriteFile("fileA.txt", "content from Claude for file A") env.WriteFile("fileB.txt", "content from Claude for file B") - // Create transcript that shows Claude created both files session.CreateTranscript("Create files A and B", []FileChange{ {Path: "fileA.txt", Content: "content from Claude for file A"}, {Path: "fileB.txt", Content: "content from Claude for file B"}, }) - // Simulate stop (creates checkpoint on shadow branch) if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { t.Fatalf("SimulateStop failed: %v", err) } - // Get HEAD before first commit headBefore := env.GetHeadHash() - // First commit: only file A (with shadow hooks - will trigger condensation) + // First commit: file A (triggers condensation) env.GitCommitWithShadowHooks("Add file A from Claude session", "fileA.txt") - // Get the checkpoint ID from first commit firstCommitHash := env.GetHeadHash() if firstCommitHash == headBefore { t.Fatal("First commit was not created") @@ -66,44 +59,31 @@ func TestShadowStrategy_LastCheckpointID_ReusedAcrossCommits(t *testing.T) { } t.Logf("First commit checkpoint ID: %s", firstCheckpointID) - // Verify LastCheckpointID was saved in session state - state, err := env.GetSessionState(session.ID) - if err != nil { - t.Fatalf("Failed to get session state: %v", err) - } - if state == nil { - t.Fatal("Session state should exist after first commit") - } - if state.LastCheckpointID.String() != firstCheckpointID { - t.Errorf("Session state LastCheckpointID = %q, want %q", state.LastCheckpointID, firstCheckpointID) + // Verify checkpoint exists on entire/checkpoints/v1 + checkpointPath := paths.CheckpointPath(id.MustCheckpointID(firstCheckpointID)) + if !env.FileExistsInBranch(paths.MetadataBranchName, checkpointPath+"/"+paths.MetadataFileName) { + t.Errorf("Checkpoint metadata should exist at %s on %s branch", + checkpointPath, paths.MetadataBranchName) } - // Second commit: file B (with hooks, but no new Claude activity) - // Should reuse the same checkpoint ID + // Second commit: file B (IDLE session, no carry-forward → no trailer) env.GitCommitWithShadowHooks("Add file B from Claude session", "fileB.txt") - // Get the checkpoint ID from second commit secondCommitHash := env.GetHeadHash() if secondCommitHash == firstCommitHash { t.Fatal("Second commit was not created") } secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash) - if secondCheckpointID == "" { - t.Fatal("Second commit should have Entire-Checkpoint trailer") - } - t.Logf("Second commit checkpoint ID: %s", secondCheckpointID) - // Both commits should have the SAME checkpoint ID - if firstCheckpointID != secondCheckpointID { - t.Errorf("Checkpoint IDs should match across commits:\n First: %s\n Second: %s", - firstCheckpointID, secondCheckpointID) - } - - // Verify the checkpoint exists on entire/checkpoints/v1 branch - checkpointPath := paths.CheckpointPath(id.MustCheckpointID(firstCheckpointID)) - if !env.FileExistsInBranch(paths.MetadataBranchName, checkpointPath+"/"+paths.MetadataFileName) { - t.Errorf("Checkpoint metadata should exist at %s on %s branch", - checkpointPath, paths.MetadataBranchName) + // In the 1:1 model, the second commit should NOT have a checkpoint trailer + // because the session was IDLE (no carry-forward for idle sessions). + if secondCheckpointID != "" { + t.Logf("Note: second commit has checkpoint ID %s (carry-forward may have activated)", secondCheckpointID) + // If carry-forward is implemented for idle sessions in the future, + // this assertion can be changed. For now, verify they're different. + if firstCheckpointID == secondCheckpointID { + t.Error("If both commits have trailers, they must have DIFFERENT checkpoint IDs (1:1 model)") + } } } @@ -215,13 +195,10 @@ func TestShadowStrategy_LastCheckpointID_NotSetWithoutCondensation(t *testing.T) } } -// TestShadowStrategy_LastCheckpointID_IgnoresOldSessions tests that when multiple -// old sessions exist in the worktree, only the current session (matching BaseCommit) -// is used for checkpoint ID reuse. -// -// This reproduces the bug where old session states from previous days would cause -// different checkpoint IDs to be used when making multiple commits from a new session. -func TestShadowStrategy_LastCheckpointID_IgnoresOldSessions(t *testing.T) { +// TestShadowStrategy_NewSessionIgnoresOldCheckpointIDs tests that when multiple +// sessions exist in the worktree, each session's commits get their own unique +// checkpoint IDs. Old session checkpoint IDs are never reused by new sessions. +func TestShadowStrategy_NewSessionIgnoresOldCheckpointIDs(t *testing.T) { t.Parallel() env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) @@ -232,7 +209,6 @@ func TestShadowStrategy_LastCheckpointID_IgnoresOldSessions(t *testing.T) { t.Fatalf("SimulateUserPromptSubmit (old session) failed: %v", err) } - // Old session modifies a file env.WriteFile("old.txt", "old session content") oldSession.CreateTranscript("Create old file", []FileChange{ {Path: "old.txt", Content: "old session content"}, @@ -250,7 +226,7 @@ func TestShadowStrategy_LastCheckpointID_IgnoresOldSessions(t *testing.T) { } t.Logf("Old session checkpoint ID: %s", oldCheckpointID) - // Make an intermediate commit (moves HEAD forward, creates new base for new session) + // Make an intermediate commit (moves HEAD forward) env.WriteFile("intermediate.txt", "unrelated change") env.GitAdd("intermediate.txt") env.GitCommit("Intermediate commit (no session)") @@ -261,53 +237,26 @@ func TestShadowStrategy_LastCheckpointID_IgnoresOldSessions(t *testing.T) { t.Fatalf("SimulateUserPromptSubmit (new session) failed: %v", err) } - // New session modifies two files env.WriteFile("fileA.txt", "new session file A") - env.WriteFile("fileB.txt", "new session file B") - newSession.CreateTranscript("Create new files A and B", []FileChange{ + newSession.CreateTranscript("Create new file A", []FileChange{ {Path: "fileA.txt", Content: "new session file A"}, - {Path: "fileB.txt", Content: "new session file B"}, }) if err := env.SimulateStop(newSession.ID, newSession.TranscriptPath); err != nil { t.Fatalf("SimulateStop (new session) failed: %v", err) } - // At this point, we have TWO session state files: - // - Old session: BaseCommit = old HEAD, LastCheckpointID = oldCheckpointID - // - New session: BaseCommit = current HEAD, FilesTouched = ["fileA.txt", "fileB.txt"] - - // First commit from new session + // Commit from new session env.GitCommitWithShadowHooks("Add file A from new session", "fileA.txt") - firstCheckpointID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) - if firstCheckpointID == "" { - t.Fatal("First commit should have checkpoint ID") + newCheckpointID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if newCheckpointID == "" { + t.Fatal("New session commit should have checkpoint ID") } - t.Logf("First new session checkpoint ID: %s", firstCheckpointID) + t.Logf("New session checkpoint ID: %s", newCheckpointID) - // CRITICAL: First checkpoint should NOT be the old session's checkpoint - if firstCheckpointID == oldCheckpointID { - t.Errorf("First new session commit reused old session checkpoint ID %s (should generate new ID)", - oldCheckpointID) - } - - // Second commit from new session - env.GitCommitWithShadowHooks("Add file B from new session", "fileB.txt") - secondCheckpointID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) - if secondCheckpointID == "" { - t.Fatal("Second commit should have checkpoint ID") - } - t.Logf("Second new session checkpoint ID: %s", secondCheckpointID) - - // CRITICAL: Both commits from new session should have SAME checkpoint ID - if firstCheckpointID != secondCheckpointID { - t.Errorf("New session commits should have same checkpoint ID:\n First: %s\n Second: %s", - firstCheckpointID, secondCheckpointID) - } - - // CRITICAL: Neither should be the old session's checkpoint ID - if secondCheckpointID == oldCheckpointID { - t.Errorf("Second new session commit reused old session checkpoint ID %s", + // CRITICAL: New session checkpoint should NOT be the old session's checkpoint + if newCheckpointID == oldCheckpointID { + t.Errorf("New session commit reused old session checkpoint ID %s (should generate new ID)", oldCheckpointID) } } @@ -363,82 +312,59 @@ func TestShadowStrategy_ShadowBranchCleanedUpAfterCondensation(t *testing.T) { } } -// TestShadowStrategy_BaseCommitUpdatedOnReuse tests that BaseCommit is updated -// even when a commit reuses a previous checkpoint ID (no new content to condense). -// This prevents the stale BaseCommit bug where subsequent commits would fall back -// to old sessions because no sessions matched the current HEAD. -func TestShadowStrategy_BaseCommitUpdatedOnReuse(t *testing.T) { +// TestShadowStrategy_BaseCommitUpdatedAfterCondensation tests that BaseCommit +// is updated to the new HEAD after condensation. This is essential for the 1:1 +// checkpoint model where each commit gets its own unique checkpoint. +func TestShadowStrategy_BaseCommitUpdatedAfterCondensation(t *testing.T) { t.Parallel() env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - // Create a session session := env.NewSession() if err := env.SimulateUserPromptSubmit(session.ID); err != nil { t.Fatalf("SimulateUserPromptSubmit failed: %v", err) } - // Claude creates two files - env.WriteFile("fileA.txt", "content A") - env.WriteFile("fileB.txt", "content B") + env.WriteFile("feature.go", "package main\nfunc Feature() {}\n") - session.CreateTranscript("Create files A and B", []FileChange{ - {Path: "fileA.txt", Content: "content A"}, - {Path: "fileB.txt", Content: "content B"}, + session.CreateTranscript("Create feature file", []FileChange{ + {Path: "feature.go", Content: "package main\nfunc Feature() {}\n"}, }) - // Stop (creates checkpoint on shadow branch) if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { t.Fatalf("SimulateStop failed: %v", err) } - // First commit: file A (with hooks - triggers condensation) - env.GitCommitWithShadowHooks("Add file A", "fileA.txt") - firstCommitHash := env.GetHeadHash() - firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash) - t.Logf("First commit (condensed): %s, checkpoint: %s", firstCommitHash[:7], firstCheckpointID) - - // Get session state after first commit - state, err := env.GetSessionState(session.ID) + // Get BaseCommit before commit + stateBefore, err := env.GetSessionState(session.ID) if err != nil { t.Fatalf("Failed to get session state: %v", err) } - baseCommitAfterFirst := state.BaseCommit - t.Logf("BaseCommit after first commit: %s", baseCommitAfterFirst[:7]) + baseCommitBefore := stateBefore.BaseCommit - // Verify BaseCommit matches first commit - if !strings.HasPrefix(firstCommitHash, baseCommitAfterFirst) { - t.Errorf("BaseCommit after first commit should match HEAD: got %s, want prefix of %s", - baseCommitAfterFirst[:7], firstCommitHash[:7]) - } - - // Second commit: file B (reuse - no new content to condense) - env.GitCommitWithShadowHooks("Add file B", "fileB.txt") - secondCommitHash := env.GetHeadHash() - secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash) - t.Logf("Second commit (reuse): %s, checkpoint: %s", secondCommitHash[:7], secondCheckpointID) + // Commit with hooks (triggers condensation) + env.GitCommitWithShadowHooks("Add feature", "feature.go") + commitHash := env.GetHeadHash() - // Verify checkpoint IDs match (reuse is correct) - if firstCheckpointID != secondCheckpointID { - t.Errorf("Second commit should reuse first checkpoint ID: got %s, want %s", - secondCheckpointID, firstCheckpointID) + checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash) + if checkpointID == "" { + t.Fatal("Commit should have Entire-Checkpoint trailer") } + t.Logf("Commit: %s, checkpoint: %s", commitHash[:7], checkpointID) - // CRITICAL: Get session state after second commit - // BaseCommit should be updated to second commit hash, not stay at first commit - state, err = env.GetSessionState(session.ID) + // BaseCommit should advance from pre-commit value to new HEAD + stateAfter, err := env.GetSessionState(session.ID) if err != nil { - t.Fatalf("Failed to get session state after second commit: %v", err) + t.Fatalf("Failed to get session state after commit: %v", err) + } + + if stateAfter.BaseCommit == baseCommitBefore { + t.Error("BaseCommit should have changed after condensation") } - baseCommitAfterSecond := state.BaseCommit - t.Logf("BaseCommit after second commit: %s", baseCommitAfterSecond[:7]) - // REGRESSION TEST: BaseCommit must be updated even without condensation - // Before the fix, BaseCommit stayed at firstCommitHash after reuse commits - if !strings.HasPrefix(secondCommitHash, baseCommitAfterSecond) { - t.Errorf("BaseCommit after reuse commit should match HEAD: got %s, want prefix of %s\n"+ - "This is a regression: BaseCommit was not updated after commit without condensation", - baseCommitAfterSecond[:7], secondCommitHash[:7]) + if !strings.HasPrefix(commitHash, stateAfter.BaseCommit) { + t.Errorf("BaseCommit should match HEAD: got %s, want prefix of %s", + stateAfter.BaseCommit[:7], commitHash[:7]) } } diff --git a/cmd/entire/cli/integration_test/phase_transitions_test.go b/cmd/entire/cli/integration_test/phase_transitions_test.go index a1f00b1ed..d117190db 100644 --- a/cmd/entire/cli/integration_test/phase_transitions_test.go +++ b/cmd/entire/cli/integration_test/phase_transitions_test.go @@ -17,14 +17,13 @@ import ( // TestShadow_CommitBeforeStop tests the "commit while agent is still working" flow. // // When the user commits while the agent is in the ACTIVE phase (between -// SimulateUserPromptSubmit and SimulateStop), the session should transition to -// ACTIVE_COMMITTED. This defers condensation because the agent is still working. -// When the agent finishes its turn (SimulateStop), the deferred condensation fires -// and the session transitions to IDLE with metadata persisted to entire/checkpoints/v1. +// SimulateUserPromptSubmit and SimulateStop), the session should stay ACTIVE +// and immediately condense. The agent continues working after the commit. +// When the agent finishes its turn (SimulateStop), the session transitions to IDLE. // // State machine transitions tested: -// - ACTIVE + GitCommit -> ACTIVE_COMMITTED (defer condensation, migrate shadow branch) -// - ACTIVE_COMMITTED + TurnEnd -> IDLE + ActionCondense (deferred condensation fires) +// - ACTIVE + GitCommit -> ACTIVE + ActionCondense (immediate condensation) +// - ACTIVE + TurnEnd -> IDLE func TestShadow_CommitBeforeStop(t *testing.T) { t.Parallel() @@ -147,7 +146,7 @@ func TestShadow_CommitBeforeStop(t *testing.T) { t.Logf("Commit has checkpoint trailer: %s", checkpointID) } - // CRITICAL: Verify session phase is ACTIVE_COMMITTED + // CRITICAL: Verify session phase stays ACTIVE (immediate condensation, no deferred state) state, err = env.GetSessionState(sess.ID) if err != nil { t.Fatalf("GetSessionState failed: %v", err) @@ -155,9 +154,9 @@ func TestShadow_CommitBeforeStop(t *testing.T) { if state == nil { t.Fatal("Session state should exist after commit") } - if state.Phase != session.PhaseActiveCommitted { + if state.Phase != session.PhaseActive { t.Errorf("Phase after commit-while-active should be %q, got %q", - session.PhaseActiveCommitted, state.Phase) + session.PhaseActive, state.Phase) } t.Logf("Session phase after mid-turn commit: %s", state.Phase) @@ -186,16 +185,13 @@ func TestShadow_CommitBeforeStop(t *testing.T) { t.Fatal("Session state should exist after stop") } if state.Phase != session.PhaseIdle { - t.Errorf("Phase after stop from ACTIVE_COMMITTED should be %q, got %q", + t.Errorf("Phase after stop from ACTIVE should be %q, got %q", session.PhaseIdle, state.Phase) } t.Logf("Session phase after stop: %s (StepCount: %d)", state.Phase, state.StepCount) - // Deferred condensation should have fired during TurnEnd (ACTIVE_COMMITTED → IDLE). - // Verify StepCount was reset and metadata was persisted to entire/checkpoints/v1. - if state.StepCount != 0 { - t.Errorf("StepCount should be 0 after TurnEnd condensation, got %d", state.StepCount) - } + // Immediate condensation should have fired during PostCommit (ACTIVE + GitCommit). + // Verify metadata was persisted to entire/checkpoints/v1. if !env.BranchExists(paths.MetadataBranchName) { t.Fatal("entire/checkpoints/v1 branch should exist after TurnEnd condensation") diff --git a/cmd/entire/cli/phase_wiring_test.go b/cmd/entire/cli/phase_wiring_test.go index 8c79d06e0..1af300856 100644 --- a/cmd/entire/cli/phase_wiring_test.go +++ b/cmd/entire/cli/phase_wiring_test.go @@ -68,28 +68,6 @@ func TestMarkSessionEnded_IdleToEnded(t *testing.T) { require.NotNil(t, loaded.EndedAt) } -// TestMarkSessionEnded_ActiveCommittedToEnded verifies ACTIVE_COMMITTED → ENDED. -func TestMarkSessionEnded_ActiveCommittedToEnded(t *testing.T) { - dir := setupGitRepoForPhaseTest(t) - t.Chdir(dir) - - state := &strategy.SessionState{ - SessionID: "test-session-end-ac", - BaseCommit: "abc123", - StartedAt: time.Now(), - Phase: session.PhaseActiveCommitted, - } - err := strategy.SaveSessionState(state) - require.NoError(t, err) - - err = markSessionEnded("test-session-end-ac") - require.NoError(t, err) - - loaded, err := strategy.LoadSessionState("test-session-end-ac") - require.NoError(t, err) - assert.Equal(t, session.PhaseEnded, loaded.Phase) -} - // TestMarkSessionEnded_AlreadyEndedIsNoop verifies ENDED → ENDED (no-op). func TestMarkSessionEnded_AlreadyEndedIsNoop(t *testing.T) { dir := setupGitRepoForPhaseTest(t) diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index ae6ccd06d..f48b1215a 100644 --- a/cmd/entire/cli/reset.go +++ b/cmd/entire/cli/reset.go @@ -161,7 +161,7 @@ func runResetSession(cmd *cobra.Command, resetter strategy.SessionResetter, sess } // activeSessionsOnCurrentHead returns sessions on the current HEAD -// that are in an active phase (ACTIVE or ACTIVE_COMMITTED). +// that are in an active phase (ACTIVE). func activeSessionsOnCurrentHead() ([]*session.State, error) { repo, err := openRepository() if err != nil { diff --git a/cmd/entire/cli/session/phase.go b/cmd/entire/cli/session/phase.go index f89975849..6b291afc0 100644 --- a/cmd/entire/cli/session/phase.go +++ b/cmd/entire/cli/session/phase.go @@ -12,14 +12,13 @@ import ( type Phase string const ( - PhaseActive Phase = "active" - PhaseActiveCommitted Phase = "active_committed" - PhaseIdle Phase = "idle" - PhaseEnded Phase = "ended" + PhaseActive Phase = "active" + PhaseIdle Phase = "idle" + PhaseEnded Phase = "ended" ) // allPhases is the canonical list of phases for enumeration (e.g., diagram generation). -var allPhases = []Phase{PhaseIdle, PhaseActive, PhaseActiveCommitted, PhaseEnded} +var allPhases = []Phase{PhaseIdle, PhaseActive, PhaseEnded} // PhaseFromString normalizes a phase string, treating empty or unknown values // as PhaseIdle for backward compatibility with pre-state-machine session files. @@ -27,20 +26,20 @@ func PhaseFromString(s string) Phase { switch Phase(s) { case PhaseActive: return PhaseActive - case PhaseActiveCommitted: - return PhaseActiveCommitted case PhaseIdle: return PhaseIdle case PhaseEnded: return PhaseEnded default: + // Backward compat: unknown phases (including removed "active_committed") + // normalize to idle. return PhaseIdle } } // IsActive reports whether the phase represents an active agent turn. func (p Phase) IsActive() bool { - return p == PhaseActive || p == PhaseActiveCommitted + return p == PhaseActive } // Event represents something that happened to a session. @@ -83,7 +82,6 @@ const ( ActionCondense Action = iota // Condense session data to permanent storage ActionCondenseIfFilesTouched // Condense only if FilesTouched is non-empty ActionDiscardIfNoFiles // Discard session if FilesTouched is empty - ActionMigrateShadowBranch // Migrate shadow branch to new HEAD ActionWarnStaleSession // Warn user about stale session(s) ActionClearEndedAt // Clear EndedAt timestamp (session re-entering) ActionUpdateLastInteraction // Update LastInteractionTime @@ -98,8 +96,6 @@ func (a Action) String() string { return "CondenseIfFilesTouched" case ActionDiscardIfNoFiles: return "DiscardIfNoFiles" - case ActionMigrateShadowBranch: - return "MigrateShadowBranch" case ActionWarnStaleSession: return "WarnStaleSession" case ActionClearEndedAt: @@ -138,8 +134,6 @@ func Transition(current Phase, event Event, ctx TransitionContext) TransitionRes return transitionFromIdle(event, ctx) case PhaseActive: return transitionFromActive(event, ctx) - case PhaseActiveCommitted: - return transitionFromActiveCommitted(event, ctx) case PhaseEnded: return transitionFromEnded(event, ctx) default: @@ -183,10 +177,7 @@ func transitionFromIdle(event Event, ctx TransitionContext) TransitionResult { func transitionFromActive(event Event, ctx TransitionContext) TransitionResult { switch event { case EventTurnStart: - // Ctrl-C recovery: just continue. - // This is a degenerate case where the EndTurn is skipped after a in-turn commit. - // Either the agent crashed or the user interrupted it. - // We choose to continue, and defer condensation to the next TurnEnd or GitCommit. + // Ctrl-C recovery: agent crashed or user interrupted mid-turn. return TransitionResult{ NewPhase: PhaseActive, Actions: []Action{ActionUpdateLastInteraction}, @@ -200,52 +191,13 @@ func transitionFromActive(event Event, ctx TransitionContext) TransitionResult { if ctx.IsRebaseInProgress { return TransitionResult{NewPhase: PhaseActive} } - return TransitionResult{ - NewPhase: PhaseActiveCommitted, - Actions: []Action{ActionMigrateShadowBranch, ActionUpdateLastInteraction}, - } - case EventSessionStart: return TransitionResult{ NewPhase: PhaseActive, - Actions: []Action{ActionWarnStaleSession}, - } - case EventSessionStop: - return TransitionResult{ - NewPhase: PhaseEnded, - Actions: []Action{ActionUpdateLastInteraction}, - } - default: - return TransitionResult{NewPhase: PhaseActive} - } -} - -func transitionFromActiveCommitted(event Event, ctx TransitionContext) TransitionResult { - switch event { - case EventTurnStart: - // Ctrl-C recovery after commit. - // This is a degenerate case where the EndTurn is skipped after a in-turn commit. - // Either the agent crashed or the user interrupted it. - // We choose to continue, and defer condensation to the next TurnEnd or GitCommit. - return TransitionResult{ - NewPhase: PhaseActive, - Actions: []Action{ActionUpdateLastInteraction}, - } - case EventTurnEnd: - return TransitionResult{ - NewPhase: PhaseIdle, Actions: []Action{ActionCondense, ActionUpdateLastInteraction}, } - case EventGitCommit: - if ctx.IsRebaseInProgress { - return TransitionResult{NewPhase: PhaseActiveCommitted} - } - return TransitionResult{ - NewPhase: PhaseActiveCommitted, - Actions: []Action{ActionMigrateShadowBranch, ActionUpdateLastInteraction}, - } case EventSessionStart: return TransitionResult{ - NewPhase: PhaseActiveCommitted, + NewPhase: PhaseActive, Actions: []Action{ActionWarnStaleSession}, } case EventSessionStop: @@ -254,7 +206,7 @@ func transitionFromActiveCommitted(event Event, ctx TransitionContext) Transitio Actions: []Action{ActionUpdateLastInteraction}, } default: - return TransitionResult{NewPhase: PhaseActiveCommitted} + return TransitionResult{NewPhase: PhaseActive} } } @@ -300,7 +252,7 @@ func transitionFromEnded(event Event, ctx TransitionContext) TransitionResult { // and EndedAt as indicated by the transition. // // Returns the subset of actions that require strategy-specific handling -// (e.g., ActionCondense, ActionMigrateShadowBranch, ActionWarnStaleSession). +// (e.g., ActionCondense, ActionWarnStaleSession). // The caller is responsible for dispatching those. func ApplyCommonActions(state *State, result TransitionResult) []Action { state.Phase = result.NewPhase @@ -314,7 +266,7 @@ func ApplyCommonActions(state *State, result TransitionResult) []Action { case ActionClearEndedAt: state.EndedAt = nil case ActionCondense, ActionCondenseIfFilesTouched, ActionDiscardIfNoFiles, - ActionMigrateShadowBranch, ActionWarnStaleSession: + ActionWarnStaleSession: // Strategy-specific actions — pass through to caller. remaining = append(remaining, action) } @@ -332,7 +284,6 @@ func MermaidDiagram() string { // State declarations with descriptions. b.WriteString(" state \"IDLE\" as idle\n") b.WriteString(" state \"ACTIVE\" as active\n") - b.WriteString(" state \"ACTIVE_COMMITTED\" as active_committed\n") b.WriteString(" state \"ENDED\" as ended\n") b.WriteString("\n") diff --git a/cmd/entire/cli/session/phase_test.go b/cmd/entire/cli/session/phase_test.go index 009b874e9..38942a10d 100644 --- a/cmd/entire/cli/session/phase_test.go +++ b/cmd/entire/cli/session/phase_test.go @@ -17,7 +17,7 @@ func TestPhaseFromString(t *testing.T) { want Phase }{ {name: "active", input: "active", want: PhaseActive}, - {name: "active_committed", input: "active_committed", want: PhaseActiveCommitted}, + {name: "active_committed", input: "active_committed", want: PhaseIdle}, {name: "idle", input: "idle", want: PhaseIdle}, {name: "ended", input: "ended", want: PhaseEnded}, {name: "empty_string_defaults_to_idle", input: "", want: PhaseIdle}, @@ -43,7 +43,6 @@ func TestPhase_IsActive(t *testing.T) { want bool }{ {name: "active_is_active", phase: PhaseActive, want: true}, - {name: "active_committed_is_active", phase: PhaseActiveCommitted, want: true}, {name: "idle_is_not_active", phase: PhaseIdle, want: false}, {name: "ended_is_not_active", phase: PhaseEnded, want: false}, } @@ -88,7 +87,6 @@ func TestAction_String(t *testing.T) { {ActionCondense, "Condense"}, {ActionCondenseIfFilesTouched, "CondenseIfFilesTouched"}, {ActionDiscardIfNoFiles, "DiscardIfNoFiles"}, - {ActionMigrateShadowBranch, "MigrateShadowBranch"}, {ActionWarnStaleSession, "WarnStaleSession"}, {ActionClearEndedAt, "ClearEndedAt"}, {ActionUpdateLastInteraction, "UpdateLastInteraction"}, @@ -192,11 +190,11 @@ func TestTransitionFromActive(t *testing.T) { wantActions: []Action{ActionUpdateLastInteraction}, }, { - name: "GitCommit_transitions_to_ACTIVE_COMMITTED", + name: "GitCommit_condenses_immediately", current: PhaseActive, event: EventGitCommit, - wantPhase: PhaseActiveCommitted, - wantActions: []Action{ActionMigrateShadowBranch, ActionUpdateLastInteraction}, + wantPhase: PhaseActive, + wantActions: []Action{ActionCondense, ActionUpdateLastInteraction}, }, { name: "GitCommit_rebase_skips_everything", @@ -223,55 +221,6 @@ func TestTransitionFromActive(t *testing.T) { }) } -func TestTransitionFromActiveCommitted(t *testing.T) { - t.Parallel() - runTransitionTests(t, []transitionCase{ - { - name: "TurnEnd_transitions_to_IDLE_with_condense", - current: PhaseActiveCommitted, - event: EventTurnEnd, - wantPhase: PhaseIdle, - wantActions: []Action{ActionCondense, ActionUpdateLastInteraction}, - }, - { - name: "GitCommit_stays_with_migrate", - current: PhaseActiveCommitted, - event: EventGitCommit, - wantPhase: PhaseActiveCommitted, - wantActions: []Action{ActionMigrateShadowBranch, ActionUpdateLastInteraction}, - }, - { - name: "GitCommit_rebase_skips_everything", - current: PhaseActiveCommitted, - event: EventGitCommit, - ctx: TransitionContext{IsRebaseInProgress: true}, - wantPhase: PhaseActiveCommitted, - wantActions: nil, - }, - { - name: "TurnStart_transitions_to_ACTIVE", - current: PhaseActiveCommitted, - event: EventTurnStart, - wantPhase: PhaseActive, - wantActions: []Action{ActionUpdateLastInteraction}, - }, - { - name: "SessionStop_transitions_to_ENDED", - current: PhaseActiveCommitted, - event: EventSessionStop, - wantPhase: PhaseEnded, - wantActions: []Action{ActionUpdateLastInteraction}, - }, - { - name: "SessionStart_warns_stale_session", - current: PhaseActiveCommitted, - event: EventSessionStart, - wantPhase: PhaseActiveCommitted, - wantActions: []Action{ActionWarnStaleSession}, - }, - }) -} - func TestTransitionFromEnded(t *testing.T) { t.Parallel() runTransitionTests(t, []transitionCase{ @@ -468,7 +417,7 @@ func TestApplyCommonActions_ClearsEndedAt(t *testing.T) { func TestApplyCommonActions_PassesThroughStrategyActions(t *testing.T) { t.Parallel() - state := &State{Phase: PhaseActiveCommitted} + state := &State{Phase: PhaseActive} result := TransitionResult{ NewPhase: PhaseIdle, Actions: []Action{ActionCondense, ActionUpdateLastInteraction}, @@ -487,14 +436,14 @@ func TestApplyCommonActions_MultipleStrategyActions(t *testing.T) { state := &State{Phase: PhaseActive} result := TransitionResult{ - NewPhase: PhaseActiveCommitted, - Actions: []Action{ActionMigrateShadowBranch, ActionUpdateLastInteraction}, + NewPhase: PhaseActive, + Actions: []Action{ActionCondense, ActionUpdateLastInteraction}, } remaining := ApplyCommonActions(state, result) - assert.Equal(t, []Action{ActionMigrateShadowBranch}, remaining) - assert.Equal(t, PhaseActiveCommitted, state.Phase) + assert.Equal(t, []Action{ActionCondense}, remaining) + assert.Equal(t, PhaseActive, state.Phase) } func TestApplyCommonActions_WarnStaleSessionPassedThrough(t *testing.T) { @@ -572,19 +521,18 @@ func TestMermaidDiagram(t *testing.T) { assert.Contains(t, diagram, "stateDiagram-v2") assert.Contains(t, diagram, "IDLE") assert.Contains(t, diagram, "ACTIVE") - assert.Contains(t, diagram, "ACTIVE_COMMITTED") assert.Contains(t, diagram, "ENDED") + assert.NotContains(t, diagram, "ACTIVE_COMMITTED") // Verify key transitions are present. assert.Contains(t, diagram, "idle --> active") - assert.Contains(t, diagram, "active --> active_committed") - assert.Contains(t, diagram, "active_committed --> idle") + assert.Contains(t, diagram, "active --> active") // ACTIVE+GitCommit stays ACTIVE assert.Contains(t, diagram, "ended --> idle") assert.Contains(t, diagram, "ended --> active") // Verify actions appear in labels. assert.Contains(t, diagram, "Condense") - assert.Contains(t, diagram, "MigrateShadowBranch") assert.Contains(t, diagram, "ClearEndedAt") assert.Contains(t, diagram, "WarnStaleSession") + assert.NotContains(t, diagram, "MigrateShadowBranch") } diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 950b84c10..bde795713 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -59,9 +59,16 @@ type State struct { // Empty means idle (backward compat with pre-state-machine files). Phase Phase `json:"phase,omitempty"` - // PendingCheckpointID is the checkpoint ID for the current commit cycle. - // Generated once when first needed, reused across all commits in the session. - PendingCheckpointID string `json:"pending_checkpoint_id,omitempty"` + // TurnID is a unique identifier for the current agent turn. + // Generated at turn start, shared across all checkpoints within the same turn. + // Used to correlate related checkpoints when a turn's work spans multiple commits. + TurnID string `json:"turn_id,omitempty"` + + // TurnCheckpointIDs tracks all checkpoint IDs condensed during the current turn. + // Set in PostCommit when a checkpoint is condensed for an ACTIVE session. + // Consumed in HandleTurnEnd to finalize all checkpoints with the full transcript. + // Cleared in InitializeSession when a new prompt starts. + TurnCheckpointIDs []string `json:"turn_checkpoint_ids,omitempty"` // LastInteractionTime is updated on every hook invocation. // Used for stale session detection in "entire doctor". diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index ddc73607a..4be567cf2 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -930,6 +930,13 @@ func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent now := time.Now() existing.LastInteractionTime = &now + // 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) + } + existing.TurnID = turnID.String() + // Backfill FirstPrompt if empty (for sessions // created before the first_prompt field was added, or resumed sessions) if existing.FirstPrompt == "" && userPrompt != "" { @@ -942,6 +949,12 @@ func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent return nil } + // Generate TurnID for the first turn + turnID, err := id.Generate() + if err != nil { + return fmt.Errorf("failed to generate turn ID: %w", err) + } + // Create new session state now := time.Now() state := &SessionState{ @@ -950,6 +963,7 @@ func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent BaseCommit: baseCommit, StartedAt: now, LastInteractionTime: &now, + TurnID: turnID.String(), StepCount: 0, // CheckpointTranscriptStart defaults to 0 (start from beginning of transcript) FilesTouched: []string{}, diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index d26575e81..8f34a2b8f 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -185,6 +185,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI AuthorName: authorName, AuthorEmail: authorEmail, Agent: state.AgentType, + TurnID: state.TurnID, TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart, CheckpointTranscriptStart: state.CheckpointTranscriptStart, TokenUsage: sessionData.TokenUsage, @@ -603,7 +604,6 @@ func (s *ManualCommitStrategy) CondenseSessionByID(sessionID string) error { state.CheckpointTranscriptStart = result.TotalTranscriptLines state.Phase = session.PhaseIdle state.LastCheckpointID = checkpointID - state.PendingCheckpointID = "" // Clear after condensation (amend handler uses LastCheckpointID) state.AttributionBaseCommit = state.BaseCommit state.PromptAttributions = nil state.PendingPromptAttribution = nil diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index cf4cb841e..acc21e371 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -115,10 +115,9 @@ func (s *ManualCommitStrategy) SaveChanges(ctx SaveContext) error { // Update session state state.StepCount++ - // Note: PendingCheckpointID is intentionally NOT cleared here. - // It is set by PostCommit (ACTIVE → ACTIVE_COMMITTED) and consumed by - // handleTurnEndCondense. Clearing it here would cause a mismatch between - // the checkpoint ID in the commit trailer and the condensed metadata. + // Note: LastCheckpointID is intentionally NOT cleared here. + // It is set during condensation and used by handleAmendCommitMsg + // to restore checkpoint trailers on amend operations. // Store the prompt attribution we calculated before saving state.PromptAttributions = append(state.PromptAttributions, promptAttr) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index b5f927957..b1f837cb6 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -210,10 +210,10 @@ func isGitSequenceOperation() bool { // - "" or "template": normal editor flow - adds trailer with explanatory comment // - "message": using -m or -F flag - prompts user interactively via /dev/tty // - "merge", "squash": skip trailer entirely (auto-generated messages) -// - "commit": amend operation - preserves existing trailer or restores from PendingCheckpointID +// - "commit": amend operation - preserves existing trailer or restores from LastCheckpointID // -func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source string) error { //nolint:maintidx // already present in codebase +func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source string) error { logCtx := logging.WithComponent(context.Background(), "checkpoint") // Skip during rebase, cherry-pick, or revert operations @@ -281,83 +281,14 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str // Check if any session has new content to condense sessionsWithContent := s.filterSessionsWithNewContent(repo, sessions) - // Determine which checkpoint ID to use - var checkpointID id.CheckpointID - var hasNewContent bool - var reusedSession *SessionState - - if len(sessionsWithContent) > 0 { - // New content exists - will generate new checkpoint ID below - hasNewContent = true - } else { - // No new content - check if any session has a LastCheckpointID to reuse - // This handles the case where user splits Claude's work across multiple commits - // Reuse if LastCheckpointID exists AND staged files overlap with session files. - // After condensation FilesTouched is nil; we fall back to condensed metadata - // so split-commit detection still works. - - // Get current HEAD to filter sessions - head, err := repo.Head() - if err != nil { - return nil //nolint:nilerr // Hook must be silent on failure - } - currentHeadHash := head.Hash().String() - - // Filter to sessions where BaseCommit matches current HEAD - // This prevents reusing checkpoint IDs from old sessions - // Note: BaseCommit is kept current both when new content is condensed (in the - // condensation process) and when no new content is found (via PostCommit when - // reusing checkpoint IDs). If none match, we don't add a trailer rather than - // falling back to old sessions which could have stale checkpoint IDs. - var currentSessions []*SessionState - for _, session := range sessions { - if session.BaseCommit == currentHeadHash { - currentSessions = append(currentSessions, session) - } - } - - if len(currentSessions) == 0 { - // No sessions match current HEAD - don't try to reuse checkpoint IDs - // from old sessions as they may be stale - logging.Debug(logCtx, "prepare-commit-msg: no sessions match current HEAD", - slog.String("strategy", "manual-commit"), - slog.String("source", source), - slog.String("current_head", truncateHash(currentHeadHash)), - slog.Int("total_sessions", len(sessions)), - ) - return nil - } - - stagedFiles := getStagedFiles(repo) - for _, session := range currentSessions { - if session.LastCheckpointID.IsEmpty() { - continue - } - filesToCheck := session.FilesTouched - // After condensation FilesTouched is reset. Fall back to the - // condensed metadata's files_touched so split-commit detection - // still works (staged files checked against previously-condensed files). - if len(filesToCheck) == 0 && session.StepCount == 0 { - if condensedFiles := s.getCondensedFilesTouched(repo, session.LastCheckpointID); len(condensedFiles) > 0 { - filesToCheck = condensedFiles - } - } - if len(filesToCheck) == 0 || hasOverlappingFiles(stagedFiles, filesToCheck) { - checkpointID = session.LastCheckpointID - reusedSession = session - break - } - } - if checkpointID.IsEmpty() { - // No new content and no previous checkpoint to reference (or staged files are unrelated) - logging.Debug(logCtx, "prepare-commit-msg: no content to link", - slog.String("strategy", "manual-commit"), - slog.String("source", source), - slog.Int("sessions_found", len(sessions)), - slog.Int("sessions_with_content", len(sessionsWithContent)), - ) - return nil - } + if len(sessionsWithContent) == 0 { + // No new content — no trailer needed + logging.Debug(logCtx, "prepare-commit-msg: no content to link", + slog.String("strategy", "manual-commit"), + slog.String("source", source), + slog.Int("sessions_found", len(sessions)), + ) + return nil } // Read current commit message @@ -368,7 +299,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str message := string(content) - // Get or generate checkpoint ID (ParseCheckpoint validates format, so found==true means valid) + // Check if trailer already exists (ParseCheckpoint validates format, so found==true means valid) if existingCpID, found := trailers.ParseCheckpoint(message); found { // Trailer already exists (e.g., amend) - keep it logging.Debug(logCtx, "prepare-commit-msg: trailer already exists", @@ -379,69 +310,31 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str return nil } - if hasNewContent { - // New content: check PendingCheckpointID first (set during previous condensation), - // otherwise generate a new one. This ensures idempotent IDs across hook invocations. - for _, state := range sessionsWithContent { - if state.PendingCheckpointID != "" { - if cpID, err := id.NewCheckpointID(state.PendingCheckpointID); err == nil { - checkpointID = cpID - break - } - } - } - if checkpointID.IsEmpty() { - cpID, err := id.Generate() - if err != nil { - return fmt.Errorf("failed to generate checkpoint ID: %w", err) - } - checkpointID = cpID - } + // Generate a fresh checkpoint ID + checkpointID, err := id.Generate() + if err != nil { + return fmt.Errorf("failed to generate checkpoint ID: %w", err) } - // Otherwise checkpointID is already set to LastCheckpointID from above // Determine agent type and last prompt from session agentType := DefaultAgentType // default for backward compatibility var lastPrompt string - if hasNewContent && len(sessionsWithContent) > 0 { - session := sessionsWithContent[0] - if session.AgentType != "" { - agentType = session.AgentType - } - lastPrompt = s.getLastPrompt(repo, session) - } else if reusedSession != nil { - // Reusing checkpoint from existing session - get agent type and prompt from that session - if reusedSession.AgentType != "" { - agentType = reusedSession.AgentType + if len(sessionsWithContent) > 0 { + firstSession := sessionsWithContent[0] + if firstSession.AgentType != "" { + agentType = firstSession.AgentType } - lastPrompt = s.getLastPrompt(repo, reusedSession) + lastPrompt = s.getLastPrompt(repo, firstSession) } // Prepare prompt for display: collapse newlines/whitespace, then truncate (rune-safe) displayPrompt := stringutil.TruncateRunes(stringutil.CollapseWhitespace(lastPrompt), 80, "...") - // Check if we're restoring an existing checkpoint ID (already condensed) - // vs linking a genuinely new checkpoint. Restoring doesn't need user confirmation - // since the data is already committed — this handles git commit --amend -m "..." - // and non-interactive environments (e.g., Claude doing commits). - isRestoringExisting := false - if !hasNewContent && reusedSession != nil { - // Reusing LastCheckpointID from a previous condensation - isRestoringExisting = true - } else if hasNewContent { - for _, state := range sessionsWithContent { - if state.PendingCheckpointID != "" { - isRestoringExisting = true - break - } - } - } - // Add trailer differently based on commit source - switch { - case source == "message" && !isRestoringExisting: - // Using -m or -F with genuinely new content: ask user interactively - // whether to add trailer (comments won't be stripped by git in this mode) + switch source { + case "message": + // Using -m or -F: ask user interactively whether to add trailer + // (comments won't be stripped by git in this mode) // Build context string for interactive prompt var promptContext string @@ -458,10 +351,6 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str return nil } message = addCheckpointTrailer(message, checkpointID) - case source == "message": - // Restoring existing checkpoint ID (amend, split commit, or non-interactive) - // No confirmation needed — data is already condensed - message = addCheckpointTrailer(message, checkpointID) default: // Normal editor flow: add trailer with explanatory comment (will be stripped by git) message = addCheckpointTrailerWithComment(message, checkpointID, string(agentType), displayPrompt) @@ -471,7 +360,6 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str slog.String("strategy", "manual-commit"), slog.String("source", source), slog.String("checkpoint_id", checkpointID.String()), - slog.Bool("has_new_content", hasNewContent), ) // Write updated message back @@ -483,7 +371,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str } // handleAmendCommitMsg handles the prepare-commit-msg hook for amend operations -// (source="commit"). It preserves existing trailers or restores from PendingCheckpointID. +// (source="commit"). It preserves existing trailers or restores from LastCheckpointID. func (s *ManualCommitStrategy) handleAmendCommitMsg(logCtx context.Context, commitMsgFile string) error { // Read current commit message content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook @@ -502,7 +390,7 @@ func (s *ManualCommitStrategy) handleAmendCommitMsg(logCtx context.Context, comm return nil } - // No trailer in message — check if any session has PendingCheckpointID to restore + // No trailer in message — check if any session has LastCheckpointID to restore worktreePath, err := GetWorktreePath() if err != nil { return nil //nolint:nilerr // Hook must be silent on failure @@ -527,29 +415,17 @@ func (s *ManualCommitStrategy) handleAmendCommitMsg(logCtx context.Context, comm } currentHead := head.Hash().String() - // Find first matching session with PendingCheckpointID or LastCheckpointID to restore. - // PendingCheckpointID is set during ACTIVE_COMMITTED (deferred condensation). + // Find first matching session with LastCheckpointID to restore. // LastCheckpointID is set after condensation completes. for _, state := range sessions { if state.BaseCommit != currentHead { continue } - var cpID id.CheckpointID - source := "" - - if state.PendingCheckpointID != "" { - if parsed, parseErr := id.NewCheckpointID(state.PendingCheckpointID); parseErr == nil { - cpID = parsed - source = "PendingCheckpointID" - } - } - if cpID.IsEmpty() && !state.LastCheckpointID.IsEmpty() { - cpID = state.LastCheckpointID - source = "LastCheckpointID" - } - if cpID.IsEmpty() { + if state.LastCheckpointID.IsEmpty() { continue } + cpID := state.LastCheckpointID + source := "LastCheckpointID" // Restore the trailer message = addCheckpointTrailer(message, cpID) @@ -575,12 +451,15 @@ func (s *ManualCommitStrategy) handleAmendCommitMsg(logCtx context.Context, comm // PostCommit is called by the git post-commit hook after a commit is created. // Uses the session state machine to determine what action to take per session: -// - ACTIVE → ACTIVE_COMMITTED: defer condensation (agent still working) +// - ACTIVE → condense immediately (each commit gets its own checkpoint) // - IDLE → condense immediately -// - ACTIVE_COMMITTED → migrate shadow branch (additional commit during same turn) // - ENDED → condense if files touched, discard if empty // -// Shadow branches are only deleted when ALL sessions sharing the branch are non-active. +// After condensation for ACTIVE sessions, remaining uncommitted files are +// carried forward to a new shadow branch so the next commit gets its own checkpoint. +// +// Shadow branches are only deleted when ALL sessions sharing the branch are non-active +// and were condensed during this PostCommit. // During rebase/cherry-pick/revert operations, phase transitions are skipped entirely. // //nolint:unparam // error return required by interface but hooks must return nil @@ -641,21 +520,12 @@ func (s *ManualCommitStrategy) PostCommit() error { // Track shadow branch names and whether they can be deleted shadowBranchesToDelete := make(map[string]struct{}) - // Track sessions that are still active AFTER transitions - activeSessionsOnBranch := make(map[string]bool) + // Track active sessions that were NOT condensed — their shadow branches must be preserved + uncondensedActiveOnBranch := make(map[string]bool) newHead := head.Hash().String() + committedFileSet := filesChangedInCommit(commit) - // Two-pass processing: condensation first, migration second. - // This prevents a migration from renaming a shadow branch before another - // session sharing that branch has had a chance to condense from it. - type pendingMigration struct { - state *SessionState - } - var pendingMigrations []pendingMigration - - // Pass 1: Run transitions and dispatch condensation/discard actions. - // Defer migration actions to pass 2. for _, state := range sessions { shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) @@ -675,6 +545,13 @@ func (s *ManualCommitStrategy) PostCommit() error { // Run the state machine transition remaining := TransitionAndLog(state, session.EventGitCommit, transitionCtx) + // Save FilesTouched BEFORE the action loop — condensation clears it, + // but we need the original list for carry-forward computation. + filesTouchedBefore := make([]string, len(state.FilesTouched)) + copy(filesTouchedBefore, state.FilesTouched) + + condensed := false + // Dispatch strategy-specific actions. // Each branch handles its own BaseCommit update so there is no // fallthrough conditional at the end. On condensation failure, @@ -685,6 +562,7 @@ func (s *ManualCommitStrategy) PostCommit() error { case session.ActionCondense: if hasNew { s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) + condensed = true // condenseAndUpdateState updates BaseCommit on success. // On failure, BaseCommit is preserved so the shadow branch remains accessible. } else { @@ -697,6 +575,7 @@ func (s *ManualCommitStrategy) PostCommit() error { // new content beyond what was previously condensed). if len(state.FilesTouched) > 0 && hasNew { s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) + condensed = true // On failure, BaseCommit is preserved (same as ActionCondense). } else { s.updateBaseCommitIfChanged(logCtx, state, newHead) @@ -708,12 +587,6 @@ func (s *ManualCommitStrategy) PostCommit() error { ) } s.updateBaseCommitIfChanged(logCtx, state, newHead) - case session.ActionMigrateShadowBranch: - // Deferred to pass 2 so condensation reads the old shadow branch first. - // Migration updates BaseCommit as part of the rename. - // Store checkpointID so HandleTurnEnd can reuse it for deferred condensation. - state.PendingCheckpointID = checkpointID.String() - pendingMigrations = append(pendingMigrations, pendingMigration{state: state}) case session.ActionClearEndedAt, session.ActionUpdateLastInteraction: // Handled by session.ApplyCommonActions above case session.ActionWarnStaleSession: @@ -721,34 +594,38 @@ func (s *ManualCommitStrategy) PostCommit() error { } } - // Save the updated state - if err := s.saveSessionState(state); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to update session state: %v\n", err) + // Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize with full transcript. + // Only ACTIVE sessions need finalization — IDLE/ENDED sessions already have complete transcripts. + if condensed && state.Phase.IsActive() { + state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String()) } - // Track whether any session on this shadow branch is still active - if state.Phase.IsActive() { - activeSessionsOnBranch[shadowBranchName] = true + // After condensation, carry forward remaining uncommitted files for ACTIVE sessions. + // This ensures that if the agent touched 3 files but only 2 were committed, + // the third file still has a shadow branch so the next commit gets its own checkpoint. + if condensed && state.Phase.IsActive() { + remainingFiles := subtractFiles(filesTouchedBefore, committedFileSet) + if len(remainingFiles) > 0 { + s.carryForwardToNewShadowBranch(logCtx, repo, state, remainingFiles) + } } - } - // Pass 2: Run deferred migrations now that all condensations are complete. - for _, pm := range pendingMigrations { - if _, migErr := s.migrateShadowBranchIfNeeded(repo, pm.state); migErr != nil { - logging.Warn(logCtx, "post-commit: shadow branch migration failed", - slog.String("session_id", pm.state.SessionID), - slog.String("error", migErr.Error()), - ) + // Save the updated state + if err := s.saveSessionState(state); err != nil { + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to update session state: %v\n", err) } - // Save the migrated state - if err := s.saveSessionState(pm.state); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to update session state after migration: %v\n", err) + + // Only preserve shadow branch for active sessions that were NOT condensed. + // Condensed sessions already have their data on entire/checkpoints/v1. + if state.Phase.IsActive() && !condensed { + uncondensedActiveOnBranch[shadowBranchName] = true } } // Clean up shadow branches — only delete when ALL sessions on the branch are non-active + // or were condensed during this PostCommit. for shadowBranchName := range shadowBranchesToDelete { - if activeSessionsOnBranch[shadowBranchName] { + if uncondensedActiveOnBranch[shadowBranchName] { logging.Debug(logCtx, "post-commit: preserving shadow branch (active session exists)", slog.String("shadow_branch", shadowBranchName), ) @@ -805,12 +682,8 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( state.PendingPromptAttribution = nil state.FilesTouched = nil - // Save checkpoint ID so subsequent commits can reuse it + // Save checkpoint ID so subsequent commits can reuse it (e.g., amend restores trailer) state.LastCheckpointID = checkpointID - // Clear PendingCheckpointID after condensation — it was used for deferred - // condensation (ACTIVE_COMMITTED flow) and should not persist. The amend - // handler uses LastCheckpointID instead. - state.PendingCheckpointID = "" shortID := state.SessionID if len(shortID) > 8 { @@ -1068,21 +941,9 @@ func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(repo *git. // (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 { - // Use PendingCheckpointID if set, otherwise generate a new one - var cpID id.CheckpointID - if state.PendingCheckpointID != "" { - var err error - cpID, err = id.NewCheckpointID(state.PendingCheckpointID) - if err != nil { - cpID = "" // fall through to generate - } - } - if cpID.IsEmpty() { - var err error - cpID, err = id.Generate() - if err != nil { - return nil //nolint:nilerr // Hook must be silent on failure - } + 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 @@ -1224,6 +1085,13 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age // Session is fully initialized — apply phase transition for TurnStart TransitionAndLog(state, session.EventTurnStart, session.TransitionContext{}) + // 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() + // Backfill AgentType if empty or set to the generic default "Agent" if !isSpecificAgentType(state.AgentType) && agentType != "" { state.AgentType = agentType @@ -1239,15 +1107,11 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age state.TranscriptPath = transcriptPath } - // Clear checkpoint IDs on every new prompt - // These are set during PostCommit when a checkpoint is created, and should be - // cleared when the user enters a new prompt (starting fresh work) - if state.LastCheckpointID != "" { - state.LastCheckpointID = "" - } - if state.PendingCheckpointID != "" { - state.PendingCheckpointID = "" - } + // 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 // Calculate attribution at prompt start (BEFORE agent makes any changes) // This captures user edits since the last checkpoint (or base commit for first prompt). @@ -1449,152 +1313,230 @@ func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *Sessio } // HandleTurnEnd dispatches strategy-specific actions emitted when an agent turn ends. -// This handles the ACTIVE_COMMITTED → IDLE transition where ActionCondense is deferred -// from PostCommit (agent was still active during the commit). +// 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. // //nolint:unparam // error return required by interface but hooks must return nil -func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState, actions []session.Action) error { - if len(actions) == 0 { - return nil +func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState, _ []session.Action) error { + // Finalize all checkpoints from this turn with the full transcript. + // Best-effort: log warnings but don't fail the hook. + s.finalizeAllTurnCheckpoints(state) + return nil +} + +// 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. +func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { + if len(state.TurnCheckpointIDs) == 0 { + return // No mid-turn commits to finalize } logCtx := logging.WithComponent(context.Background(), "checkpoint") - for _, action := range actions { - switch action { - case session.ActionCondense: - s.handleTurnEndCondense(logCtx, state) - case session.ActionCondenseIfFilesTouched, session.ActionDiscardIfNoFiles, - session.ActionMigrateShadowBranch, session.ActionWarnStaleSession: - // Not expected at turn-end; log for diagnostics. - logging.Debug(logCtx, "turn-end: unexpected action", - slog.String("action", action.String()), - slog.String("session_id", state.SessionID), - ) - case session.ActionClearEndedAt, session.ActionUpdateLastInteraction: - // Handled by session.ApplyCommonActions before this is called. - } + logging.Info(logCtx, "finalizing turn checkpoints with full transcript", + slog.String("session_id", state.SessionID), + slog.Int("checkpoint_count", len(state.TurnCheckpointIDs)), + ) + + // Read full transcript from live transcript file + if state.TranscriptPath == "" { + logging.Warn(logCtx, "finalize: no transcript path, skipping", + slog.String("session_id", state.SessionID), + ) + state.TurnCheckpointIDs = nil + return } - return nil -} -// handleTurnEndCondense performs deferred condensation at turn end. -func (s *ManualCommitStrategy) handleTurnEndCondense(logCtx context.Context, state *SessionState) { - repo, err := OpenRepository() - if err != nil { - logging.Warn(logCtx, "turn-end condense: failed to open repo", - slog.String("error", err.Error())) + fullTranscript, err := os.ReadFile(state.TranscriptPath) + if err != nil || len(fullTranscript) == 0 { + logging.Warn(logCtx, "finalize: failed to read transcript, skipping", + slog.String("session_id", state.SessionID), + slog.String("transcript_path", state.TranscriptPath), + ) + state.TurnCheckpointIDs = nil return } - head, err := repo.Head() + // Extract prompts and context from the full transcript + prompts := extractUserPrompts(state.AgentType, string(fullTranscript)) + contextBytes := generateContextFromPrompts(prompts) + + // Open repository and create checkpoint store + repo, err := OpenRepository() if err != nil { - logging.Warn(logCtx, "turn-end condense: failed to get HEAD", - slog.String("error", err.Error())) + logging.Warn(logCtx, "finalize: failed to open repository", + slog.String("error", err.Error()), + ) + state.TurnCheckpointIDs = nil return } + store := checkpoint.NewGitStore(repo) - // Derive checkpoint ID from PendingCheckpointID (set during PostCommit), - // or generate a new one if not set. - var checkpointID id.CheckpointID - if state.PendingCheckpointID != "" { - if cpID, parseErr := id.NewCheckpointID(state.PendingCheckpointID); parseErr == nil { - checkpointID = cpID + // 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()), + ) + continue } - } - if checkpointID.IsEmpty() { - cpID, genErr := id.Generate() - if genErr != nil { - logging.Warn(logCtx, "turn-end condense: failed to generate checkpoint ID", - slog.String("error", genErr.Error())) - return + + updateErr := store.UpdateCommitted(context.Background(), checkpoint.UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: state.SessionID, + Transcript: fullTranscript, + Prompts: prompts, + Context: contextBytes, + }) + if updateErr != nil { + logging.Warn(logCtx, "finalize: failed to update checkpoint", + slog.String("checkpoint_id", cpIDStr), + slog.String("error", updateErr.Error()), + ) + continue } - checkpointID = cpID - } - // Check if there is actually new content to condense. - // Fail-open: if content check errors, assume new content so we don't silently skip. - hasNew, contentErr := s.sessionHasNewContent(repo, state) - if contentErr != nil { - hasNew = true - logging.Debug(logCtx, "turn-end condense: error checking content, assuming new", + logging.Info(logCtx, "finalize: checkpoint updated with full transcript", + slog.String("checkpoint_id", cpIDStr), slog.String("session_id", state.SessionID), - slog.String("error", contentErr.Error())) + ) } - if !hasNew { - logging.Debug(logCtx, "turn-end condense: no new content", - slog.String("session_id", state.SessionID)) - return + // Update transcript start and clear turn checkpoint IDs + fullTranscriptLines := countTranscriptItems(state.AgentType, string(fullTranscript)) + state.CheckpointTranscriptStart = fullTranscriptLines + state.TurnCheckpointIDs = nil +} + +// hasOverlappingFiles checks if any file in stagedFiles appears in filesTouched. +func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + touchedSet[f] = true } - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - shadowBranchesToDelete := map[string]struct{}{} + for _, staged := range stagedFiles { + if touchedSet[staged] { + return true + } + } + return false +} - s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) +// filesChangedInCommit returns the set of files changed in a commit by diffing against its parent. +func filesChangedInCommit(commit *object.Commit) map[string]struct{} { + result := make(map[string]struct{}) - // Delete shadow branches after condensation — but only if no other active - // sessions share the branch (same safety check PostCommit uses). - for branchName := range shadowBranchesToDelete { - if s.hasOtherActiveSessionsOnBranch(state.SessionID, state.BaseCommit, state.WorktreeID) { - logging.Debug(logCtx, "turn-end: preserving shadow branch (other active session exists)", - slog.String("shadow_branch", branchName)) - continue + commitTree, err := commit.Tree() + if err != nil { + return result + } + + var parentTree *object.Tree + if commit.NumParents() > 0 { + parent, err := commit.Parent(0) + if err != nil { + return result } - if err := deleteShadowBranch(repo, branchName); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to clean up %s: %v\n", branchName, err) - } else { - fmt.Fprintf(os.Stderr, "[entire] Cleaned up shadow branch: %s\n", branchName) - logging.Info(logCtx, "shadow branch deleted (turn-end)", - slog.String("strategy", "manual-commit"), - slog.String("shadow_branch", branchName), - ) + parentTree, err = parent.Tree() + if err != nil { + return result } } -} -// hasOtherActiveSessionsOnBranch checks if any other sessions with the same -// base commit and worktree ID are in an active phase. Used to prevent deleting -// a shadow branch that another session still needs. -func (s *ManualCommitStrategy) hasOtherActiveSessionsOnBranch(currentSessionID, baseCommit, worktreeID string) bool { - sessions, err := s.findSessionsForCommit(baseCommit) + if parentTree == nil { + // Initial commit — all files are new + if iterErr := commitTree.Files().ForEach(func(f *object.File) error { + result[f.Name] = struct{}{} + return nil + }); iterErr != nil { + return result + } + return result + } + + changes, err := parentTree.Diff(commitTree) if err != nil { - return false // Fail-open: if we can't check, don't block deletion + return result } - for _, other := range sessions { - if other.SessionID == currentSessionID { - continue - } - if other.WorktreeID == worktreeID && other.Phase.IsActive() { - return true + for _, change := range changes { + name := change.To.Name + if name == "" { + name = change.From.Name } + result[name] = struct{}{} } - return false + return result } -// getCondensedFilesTouched reads the files_touched list from the last condensed -// checkpoint metadata on entire/checkpoints/v1. Used to check staged-file overlap -// after FilesTouched has been reset by condensation. -func (s *ManualCommitStrategy) getCondensedFilesTouched(repo *git.Repository, cpID id.CheckpointID) []string { - store := checkpoint.NewGitStore(repo) - summary, err := store.ReadCommitted(context.Background(), cpID) - if err != nil || summary == nil { - return nil +// 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 summary.FilesTouched + return remaining } -// hasOverlappingFiles checks if any file in stagedFiles appears in filesTouched. -func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { - touchedSet := make(map[string]bool) - for _, f := range filesTouched { - touchedSet[f] = true - } +// 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( + logCtx context.Context, + repo *git.Repository, + state *SessionState, + remainingFiles []string, +) { + store := checkpoint.NewGitStore(repo) - for _, staged := range stagedFiles { - if touchedSet[staged] { - return true - } + metadataDir := paths.SessionMetadataDirFromSessionID(state.SessionID) + metadataDirAbs := filepath.Join(state.WorktreePath, metadataDir) + + result, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ + SessionID: state.SessionID, + BaseCommit: state.BaseCommit, + WorktreeID: state.WorktreeID, + ModifiedFiles: remainingFiles, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "carry forward: uncommitted session files", + IsFirstCheckpoint: false, + }) + if err != nil { + logging.Warn(logCtx, "post-commit: carry-forward failed", + slog.String("session_id", state.SessionID), + slog.String("error", err.Error()), + ) + return } - return false + 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: prompt-level carry-forward means + // the next condensation re-processes the full transcript so the checkpoint is self-contained. + state.FilesTouched = remainingFiles + state.StepCount = 1 + state.CheckpointTranscriptStart = 0 + state.LastCheckpointID = "" + + logging.Info(logCtx, "post-commit: carried forward remaining files", + slog.String("session_id", state.SessionID), + slog.Int("remaining_files", len(remainingFiles)), + ) } diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index 7de37c777..e9e9a6d3a 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -8,6 +8,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/buildinfo" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v5" @@ -215,6 +216,12 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID untrackedFiles = nil } + // Generate TurnID for the first turn + turnID, err := id.Generate() + if err != nil { + return nil, fmt.Errorf("failed to generate turn ID: %w", err) + } + now := time.Now() headHash := head.Hash().String() state := &SessionState{ @@ -226,6 +233,7 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID WorktreeID: worktreeID, StartedAt: now, LastInteractionTime: &now, + TurnID: turnID.String(), StepCount: 0, UntrackedFilesAtStart: untrackedFiles, AgentType: agentType, diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 07aec19c6..472da847c 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -1479,7 +1479,6 @@ func TestShadowStrategy_CondenseSession_EphemeralBranchTrailer(t *testing.T) { t.Fatalf("failed to create metadata dir: %v", err) } - //nolint:goconst // test data repeated across test functions transcript := `{"type":"human","message":{"content":"test prompt"}} {"type":"assistant","message":{"content":"test response"}} ` diff --git a/cmd/entire/cli/strategy/mid_turn_commit_test.go b/cmd/entire/cli/strategy/mid_turn_commit_test.go index 7f24b854d..1b8eb2d1c 100644 --- a/cmd/entire/cli/strategy/mid_turn_commit_test.go +++ b/cmd/entire/cli/strategy/mid_turn_commit_test.go @@ -155,63 +155,3 @@ func TestPostCommit_NoTrailer_UpdatesBaseCommit(t *testing.T) { assert.Equal(t, session.PhaseActive, state.Phase, "Phase should remain ACTIVE when commit has no trailer") } - -// TestSaveChanges_PreservesPendingCheckpointID verifies that SaveChanges does NOT -// clear PendingCheckpointID. This field is set by PostCommit for deferred condensation -// and should persist through SaveChanges calls until consumed by handleTurnEndCondense. -// -// Bug: SaveChanges clears PendingCheckpointID at line ~120. When the agent stops, -// handleTurnEndCondense finds it empty, generates a new ID, and the commit trailer -// and condensed data end up with different IDs. -func TestSaveChanges_PreservesPendingCheckpointID(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-preserve-pending-cpid" - - // Initialize session and save first checkpoint - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Set PendingCheckpointID (simulating what PostCommit does) - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - const testPendingCpID = "abc123def456" - state.PendingCheckpointID = testPendingCpID - state.Phase = session.PhaseActiveCommitted - require.NoError(t, s.saveSessionState(state)) - - // Create metadata for a new checkpoint - metadataDir := ".entire/metadata/" + sessionID - metadataDirAbs := filepath.Join(dir, metadataDir) - // Transcript already exists from setupSessionWithCheckpoint - - // Modify a file so the checkpoint has real changes - testFile := filepath.Join(dir, "src", "new_file.go") - require.NoError(t, os.MkdirAll(filepath.Dir(testFile), 0o755)) - require.NoError(t, os.WriteFile(testFile, []byte("package src\n"), 0o644)) - - // Call SaveChanges — this should NOT clear PendingCheckpointID - err = s.SaveChanges(SaveContext{ - SessionID: sessionID, - ModifiedFiles: []string{}, - NewFiles: []string{"src/new_file.go"}, - DeletedFiles: []string{}, - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - CommitMessage: "Checkpoint 2", - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) - require.NoError(t, err) - - // Reload state and verify PendingCheckpointID is preserved - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - assert.Equal(t, testPendingCpID, state.PendingCheckpointID, - "PendingCheckpointID should be preserved across SaveChanges calls, "+ - "not cleared — it's needed for deferred condensation at turn end") -} diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 0d1115908..cc735974d 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -20,10 +20,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestPostCommit_ActiveSession_NoCondensation verifies that PostCommit on an -// ACTIVE session transitions to ACTIVE_COMMITTED without condensing. -// The shadow branch must be preserved because the session is still active. -func TestPostCommit_ActiveSession_NoCondensation(t *testing.T) { +// TestPostCommit_ActiveSession_CondensesImmediately verifies that PostCommit on +// an ACTIVE session condenses immediately and stays ACTIVE. +// With the 1:1 checkpoint model, each commit gets its own checkpoint right away. +func TestPostCommit_ActiveSession_CondensesImmediately(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) @@ -49,20 +49,21 @@ func TestPostCommit_ActiveSession_NoCondensation(t *testing.T) { err = s.PostCommit() require.NoError(t, err) - // Verify phase transitioned to ACTIVE_COMMITTED + // Verify phase stays ACTIVE (immediate condensation, no deferred phase) state, err = s.loadSessionState(sessionID) require.NoError(t, err) require.NotNil(t, state) - assert.Equal(t, session.PhaseActiveCommitted, state.Phase, - "ACTIVE session should transition to ACTIVE_COMMITTED on GitCommit") + assert.Equal(t, session.PhaseActive, state.Phase, + "ACTIVE session should stay ACTIVE after immediate condensation on GitCommit") - // Verify shadow branch is NOT deleted (session is still active). - // After PostCommit, BaseCommit is updated to new HEAD, so use the current state. - shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - refName := plumbing.NewBranchReferenceName(shadowBranch) - _, err = repo.Reference(refName, true) - assert.NoError(t, err, - "shadow branch should be preserved when session is still active") + // Verify condensation happened: the entire/checkpoints/v1 branch should exist + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err, "entire/checkpoints/v1 branch should exist after immediate condensation") + assert.NotNil(t, sessionsRef) + + // Verify StepCount was reset to 0 by condensation + assert.Equal(t, 0, state.StepCount, + "StepCount should be reset after immediate condensation") } // TestPostCommit_IdleSession_Condenses verifies that PostCommit on an IDLE @@ -170,10 +171,12 @@ func TestPostCommit_RebaseDuringActive_SkipsTransition(t *testing.T) { "shadow branch should be preserved during rebase") } -// TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists verifies that -// the shadow branch is preserved when ANY session on it is still active, -// even if another session on the same branch is IDLE and gets condensed. -func TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists(t *testing.T) { +// TestPostCommit_ShadowBranch_PreservedWhenUncondensedActiveSessionExists verifies that +// the shadow branch is preserved when an ACTIVE session that was NOT condensed +// (e.g., it has no new content) still shares the branch with another session. +// Both sessions condense immediately on GitCommit, but the branch is only deleted +// when ALL sessions on it have been condensed. +func TestPostCommit_ShadowBranch_PreservedWhenUncondensedActiveSessionExists(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) @@ -199,8 +202,10 @@ func TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists(t *testing.T) idleState.LastInteractionTime = nil require.NoError(t, s.saveSessionState(idleState)) - // Create a second session with the SAME base commit and worktree (concurrent session) - // Save the active session with ACTIVE phase and some checkpoints + // Create a second session with the SAME base commit and worktree (concurrent session). + // This session is ACTIVE but has NO checkpoints (StepCount=0, no shadow branch content). + // Because it has no new content, it will NOT be condensed, and its shadow branch must + // be preserved so it can save checkpoints later. now := time.Now() activeState := &SessionState{ SessionID: activeSessionID, @@ -210,10 +215,13 @@ func TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists(t *testing.T) StartedAt: now, Phase: session.PhaseActive, LastInteractionTime: &now, - StepCount: 1, + StepCount: 0, } require.NoError(t, s.saveSessionState(activeState)) + // Record shadow branch name before PostCommit + shadowBranch := getShadowBranchNameForCommit(baseCommit, worktreeID) + // Create a commit WITH the checkpoint trailer commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3") @@ -221,11 +229,11 @@ func TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists(t *testing.T) err = s.PostCommit() require.NoError(t, err) - // Verify the ACTIVE session's phase is now ACTIVE_COMMITTED + // Verify the ACTIVE session stays ACTIVE (immediate condensation model) activeState, err = s.loadSessionState(activeSessionID) require.NoError(t, err) - assert.Equal(t, session.PhaseActiveCommitted, activeState.Phase, - "ACTIVE session should transition to ACTIVE_COMMITTED on GitCommit") + assert.Equal(t, session.PhaseActive, activeState.Phase, + "ACTIVE session should stay ACTIVE after GitCommit") // Verify the IDLE session actually condensed (entire/checkpoints/v1 branch should exist) idleState, err = s.loadSessionState(idleSessionID) @@ -238,13 +246,12 @@ func TestPostCommit_ShadowBranch_PreservedWhenActiveSessionExists(t *testing.T) assert.Equal(t, 0, idleState.StepCount, "IDLE session StepCount should be reset after condensation") - // Verify shadow branch is NOT deleted because the ACTIVE session still needs it. - // After PostCommit, BaseCommit is updated to new HEAD via migration. - newShadowBranch := getShadowBranchNameForCommit(activeState.BaseCommit, activeState.WorktreeID) - refName := plumbing.NewBranchReferenceName(newShadowBranch) + // Verify shadow branch is preserved because the ACTIVE session was not condensed + // (it had no new content) and still needs the branch for future checkpoints. + refName := plumbing.NewBranchReferenceName(shadowBranch) _, err = repo.Reference(refName, true) assert.NoError(t, err, - "shadow branch should be preserved when an active session still exists on it") + "shadow branch should be preserved when an uncondensed active session still exists on it") } // TestPostCommit_CondensationFailure_PreservesShadowBranch verifies that when @@ -544,74 +551,6 @@ func TestPostCommit_EndedSession_NoFilesTouched_Discards(t *testing.T) { "ENDED session should stay ENDED on discard path") } -// TestPostCommit_ActiveCommitted_MigratesShadowBranch verifies that an -// ACTIVE_COMMITTED session receiving another commit migrates the shadow branch -// to the new HEAD and stays in ACTIVE_COMMITTED. -func TestPostCommit_ActiveCommitted_MigratesShadowBranch(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-postcommit-ac-migrate" - - // Initialize session and save a checkpoint - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Set phase to ACTIVE_COMMITTED - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - state.Phase = session.PhaseActiveCommitted - require.NoError(t, s.saveSessionState(state)) - - // Record original shadow branch name and BaseCommit - originalShadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - originalStepCount := state.StepCount - - // Create a commit with the checkpoint trailer - commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c4") - - // Run PostCommit - err = s.PostCommit() - require.NoError(t, err) - - // Verify phase stays ACTIVE_COMMITTED - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - assert.Equal(t, session.PhaseActiveCommitted, state.Phase, - "ACTIVE_COMMITTED session should stay ACTIVE_COMMITTED on subsequent commit") - - // Verify BaseCommit updated to new HEAD - head, err := repo.Head() - require.NoError(t, err) - assert.Equal(t, head.Hash().String(), state.BaseCommit, - "BaseCommit should be updated to new HEAD after migration") - - // Verify new shadow branch exists at new HEAD hash - newShadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - newRefName := plumbing.NewBranchReferenceName(newShadowBranch) - _, err = repo.Reference(newRefName, true) - require.NoError(t, err, - "new shadow branch should exist after migration") - - // Verify original shadow branch no longer exists (was migrated/renamed) - oldRefName := plumbing.NewBranchReferenceName(originalShadowBranch) - _, err = repo.Reference(oldRefName, true) - require.Error(t, err, - "original shadow branch should no longer exist after migration") - - // StepCount unchanged (no condensation) - assert.Equal(t, originalStepCount, state.StepCount, - "StepCount should be unchanged - no condensation for ACTIVE_COMMITTED") - - // entire/checkpoints/v1 branch should NOT exist (no condensation) - _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - require.Error(t, err, - "entire/checkpoints/v1 branch should NOT exist - no condensation for ACTIVE_COMMITTED") -} - // TestPostCommit_CondensationFailure_EndedSession_PreservesShadowBranch verifies // that when condensation fails for an ENDED session with files touched, // BaseCommit is preserved (not updated). @@ -671,298 +610,6 @@ func TestPostCommit_CondensationFailure_EndedSession_PreservesShadowBranch(t *te "ENDED session should stay ENDED when condensation fails") } -// TestPostCommit_ActiveSession_SetsPendingCheckpointID verifies that PostCommit -// stores PendingCheckpointID when transitioning ACTIVE → ACTIVE_COMMITTED. -// This ensures HandleTurnEnd can reuse the same checkpoint ID that's in the -// commit trailer, rather than generating a mismatched one. -func TestPostCommit_ActiveSession_SetsPendingCheckpointID(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-postcommit-pending-cpid" - - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Set phase to ACTIVE (agent mid-turn) - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - state.Phase = session.PhaseActive - state.PendingCheckpointID = "" // Ensure it starts empty - require.NoError(t, s.saveSessionState(state)) - - // Create a commit with a known checkpoint ID - commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") - - // Run PostCommit - err = s.PostCommit() - require.NoError(t, err) - - // Verify phase transitioned to ACTIVE_COMMITTED - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - require.Equal(t, session.PhaseActiveCommitted, state.Phase) - - // Verify PendingCheckpointID was stored from the commit trailer - assert.Equal(t, "a1b2c3d4e5f6", state.PendingCheckpointID, - "PendingCheckpointID should be set to the commit's checkpoint ID for deferred condensation") -} - -// TestTurnEnd_ActiveCommitted_ReusesCheckpointID verifies that HandleTurnEnd -// uses PendingCheckpointID (set by PostCommit) rather than generating a new one. -// This ensures the condensed metadata matches the commit trailer. -func TestTurnEnd_ActiveCommitted_ReusesCheckpointID(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-turnend-reuses-cpid" - - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Simulate PostCommit: transition to ACTIVE_COMMITTED with PendingCheckpointID - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - state.Phase = session.PhaseActive - require.NoError(t, s.saveSessionState(state)) - - commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3") - - err = s.PostCommit() - require.NoError(t, err) - - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - require.Equal(t, "d4e5f6a1b2c3", state.PendingCheckpointID) - - // Run TurnEnd - result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) - remaining := session.ApplyCommonActions(state, result) - - err = s.HandleTurnEnd(state, remaining) - require.NoError(t, err) - - // Verify the condensed checkpoint ID matches the commit trailer - // The LastCheckpointID is set by condenseAndUpdateState on success - assert.Equal(t, id.CheckpointID("d4e5f6a1b2c3"), state.LastCheckpointID, - "condensation should use PendingCheckpointID, not generate a new one") -} - -// TestTurnEnd_ConcurrentSession_PreservesShadowBranch verifies that -// HandleTurnEnd does NOT delete the shadow branch when another active -// session shares it. -func TestTurnEnd_ConcurrentSession_PreservesShadowBranch(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID1 := "test-turnend-concurrent-1" - sessionID2 := "test-turnend-concurrent-2" - - // Initialize first session and save a checkpoint - setupSessionWithCheckpoint(t, s, repo, dir, sessionID1) - - // Get worktree info from first session - state1, err := s.loadSessionState(sessionID1) - require.NoError(t, err) - worktreePath := state1.WorktreePath - baseCommit := state1.BaseCommit - worktreeID := state1.WorktreeID - - // Transition first session through PostCommit to ACTIVE_COMMITTED - state1.Phase = session.PhaseActive - require.NoError(t, s.saveSessionState(state1)) - - commitWithCheckpointTrailer(t, repo, dir, "e5f6a1b2c3d4") - - err = s.PostCommit() - require.NoError(t, err) - - state1, err = s.loadSessionState(sessionID1) - require.NoError(t, err) - require.Equal(t, session.PhaseActiveCommitted, state1.Phase) - - // Create a second session with the SAME base commit and worktree (concurrent) - now := time.Now() - state2 := &SessionState{ - SessionID: sessionID2, - BaseCommit: state1.BaseCommit, // Same base commit (post-migration) - WorktreePath: worktreePath, - WorktreeID: worktreeID, - StartedAt: now, - Phase: session.PhaseActive, - LastInteractionTime: &now, - StepCount: 1, - } - require.NoError(t, s.saveSessionState(state2)) - - // Record shadow branch name (shared by both sessions) - shadowBranch := getShadowBranchNameForCommit(state1.BaseCommit, state1.WorktreeID) - - // First session ends its turn — should NOT delete shadow branch - result := session.Transition(state1.Phase, session.EventTurnEnd, session.TransitionContext{}) - remaining := session.ApplyCommonActions(state1, result) - - err = s.HandleTurnEnd(state1, remaining) - require.NoError(t, err) - - // Shadow branch at the pre-condensation BaseCommit should be preserved - // because session2 is still active on it. - refName := plumbing.NewBranchReferenceName(shadowBranch) - _, err = repo.Reference(refName, true) - require.NoError(t, err, - "shadow branch should be preserved when another active session shares it") - - // Condensation still succeeded for session1 - assert.Equal(t, 0, state1.StepCount, - "StepCount should be reset after condensation") - assert.Equal(t, session.PhaseIdle, state1.Phase, - "first session should be IDLE after turn end") - - // Second session is still active and unaffected - state2, err = s.loadSessionState(sessionID2) - require.NoError(t, err) - assert.Equal(t, session.PhaseActive, state2.Phase, - "second session should still be ACTIVE") - _ = baseCommit // used for documentation -} - -// TestTurnEnd_ActiveCommitted_CondensesSession verifies that HandleTurnEnd -// with ActionCondense (from ACTIVE_COMMITTED → IDLE) condenses the session -// to entire/checkpoints/v1 and cleans up the shadow branch. -func TestTurnEnd_ActiveCommitted_CondensesSession(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-turnend-condenses" - - // Initialize session and save a checkpoint so there is shadow branch content - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Simulate PostCommit: create a commit with trailer and transition to ACTIVE_COMMITTED - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - state.Phase = session.PhaseActive - require.NoError(t, s.saveSessionState(state)) - - commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") - - // Run PostCommit so phase transitions to ACTIVE_COMMITTED and PendingCheckpointID is set - err = s.PostCommit() - require.NoError(t, err) - - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - require.Equal(t, session.PhaseActiveCommitted, state.Phase) - - // Now simulate the TurnEnd transition that the handler dispatches - result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) - remaining := session.ApplyCommonActions(state, result) - - // Verify the state machine emits ActionCondense - require.Contains(t, remaining, session.ActionCondense, - "ACTIVE_COMMITTED + TurnEnd should emit ActionCondense") - - // Record shadow branch name BEFORE HandleTurnEnd (BaseCommit may change) - shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - - // Call HandleTurnEnd with the remaining actions - err = s.HandleTurnEnd(state, remaining) - require.NoError(t, err) - - // Verify condensation happened: entire/checkpoints/v1 branch should exist - sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - require.NoError(t, err, "entire/checkpoints/v1 branch should exist after turn-end condensation") - assert.NotNil(t, sessionsRef) - - // Verify shadow branch IS deleted after condensation - refName := plumbing.NewBranchReferenceName(shadowBranch) - _, err = repo.Reference(refName, true) - require.Error(t, err, - "shadow branch should be deleted after turn-end condensation") - - // Verify StepCount was reset by condensation - assert.Equal(t, 0, state.StepCount, - "StepCount should be reset after condensation") - - // Verify phase is IDLE (set by ApplyCommonActions above) - assert.Equal(t, session.PhaseIdle, state.Phase, - "phase should be IDLE after TurnEnd") -} - -// TestTurnEnd_ActiveCommitted_CondensationFailure_PreservesShadowBranch verifies -// that when HandleTurnEnd condensation fails, BaseCommit is NOT updated and -// the shadow branch is preserved. -func TestTurnEnd_ActiveCommitted_CondensationFailure_PreservesShadowBranch(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-turnend-condense-fail" - - // Initialize session and save a checkpoint - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - - // Simulate PostCommit: transition to ACTIVE_COMMITTED - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - state.Phase = session.PhaseActive - require.NoError(t, s.saveSessionState(state)) - - commitWithCheckpointTrailer(t, repo, dir, "b2c3d4e5f6a1") - - err = s.PostCommit() - require.NoError(t, err) - - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - require.Equal(t, session.PhaseActiveCommitted, state.Phase) - - // Record original BaseCommit before corruption - originalBaseCommit := state.BaseCommit - originalStepCount := state.StepCount - - // Corrupt shadow branch by pointing it at ZeroHash - shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) - corruptRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), plumbing.ZeroHash) - require.NoError(t, repo.Storer.SetReference(corruptRef)) - - // Run the transition - result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) - remaining := session.ApplyCommonActions(state, result) - - // Call HandleTurnEnd — condensation should fail silently - err = s.HandleTurnEnd(state, remaining) - require.NoError(t, err, "HandleTurnEnd should not return error even when condensation fails") - - // BaseCommit should NOT be updated (condensation failed) - assert.Equal(t, originalBaseCommit, state.BaseCommit, - "BaseCommit should NOT be updated when condensation fails") - assert.Equal(t, originalStepCount, state.StepCount, - "StepCount should NOT be reset when condensation fails") - - // entire/checkpoints/v1 branch should NOT exist (condensation failed) - _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - assert.Error(t, err, - "entire/checkpoints/v1 branch should NOT exist when condensation fails") -} - // TestTurnEnd_Active_NoActions verifies that HandleTurnEnd with no actions // is a no-op (normal ACTIVE → IDLE transition has no strategy-specific actions). func TestTurnEnd_Active_NoActions(t *testing.T) { @@ -1013,97 +660,6 @@ func TestTurnEnd_Active_NoActions(t *testing.T) { "shadow branch should still exist after no-op turn end") } -// TestTurnEnd_DeferredCondensation_AttributionUsesOriginalBase verifies that -// deferred condensation (ACTIVE_COMMITTED → IDLE) uses AttributionBaseCommit -// instead of BaseCommit for attribution, so the diff is non-zero. -// -// Scenario: agent modifies a file, user commits mid-turn, then turn ends. -// Without the fix, BaseCommit is updated to the new HEAD by PostCommit migration, -// so baseTree == headTree and attribution shows zero changes. -// With the fix, AttributionBaseCommit preserves the original base commit. -func TestTurnEnd_DeferredCondensation_AttributionUsesOriginalBase(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - repo, err := git.PlainOpen(dir) - require.NoError(t, err) - - s := &ManualCommitStrategy{} - sessionID := "test-deferred-attribution" - - // Initialize session and save a checkpoint that includes a modified file. - // The "agent" modifies test.txt before saving the checkpoint. - setupSessionWithFileChange(t, s, repo, dir, sessionID) - - // Record the original base commit (commit A) - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - originalBaseCommit := state.BaseCommit - - // Verify AttributionBaseCommit is set at session init - assert.Equal(t, originalBaseCommit, state.AttributionBaseCommit, - "AttributionBaseCommit should equal BaseCommit at session start") - - // Set phase to ACTIVE (simulating agent mid-turn) - state.Phase = session.PhaseActive - state.FilesTouched = []string{"test.txt"} - require.NoError(t, s.saveSessionState(state)) - - // User commits (creates commit B). This triggers PostCommit which: - // - Transitions ACTIVE → ACTIVE_COMMITTED (defers condensation) - // - Migrates shadow branch to new HEAD - // - Updates BaseCommit to new HEAD - commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") - - err = s.PostCommit() - require.NoError(t, err) - - // Reload state and verify the key invariant: - // BaseCommit has moved to the new HEAD, but AttributionBaseCommit stays at original - state, err = s.loadSessionState(sessionID) - require.NoError(t, err) - require.Equal(t, session.PhaseActiveCommitted, state.Phase) - - head, err := repo.Head() - require.NoError(t, err) - newHeadHash := head.Hash().String() - - assert.Equal(t, newHeadHash, state.BaseCommit, - "BaseCommit should be updated to new HEAD after migration") - assert.Equal(t, originalBaseCommit, state.AttributionBaseCommit, - "AttributionBaseCommit should still point to original base (commit A)") - assert.NotEqual(t, state.BaseCommit, state.AttributionBaseCommit, - "BaseCommit and AttributionBaseCommit should diverge after mid-turn commit") - - // Now simulate TurnEnd (agent finishes) — deferred condensation runs - result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) - remaining := session.ApplyCommonActions(state, result) - require.Contains(t, remaining, session.ActionCondense) - - err = s.HandleTurnEnd(state, remaining) - require.NoError(t, err) - - // After condensation, verify AttributionBaseCommit is updated to match BaseCommit - assert.Equal(t, state.BaseCommit, state.AttributionBaseCommit, - "AttributionBaseCommit should be updated after successful condensation") - - // Verify condensation actually happened - _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - require.NoError(t, err, "entire/sessions branch should exist after deferred condensation") - - // Read back the committed metadata and verify attribution is non-zero. - // The agent modified test.txt (added a line), so AgentLines should be > 0. - store := checkpoint.NewGitStore(repo) - cpID := id.MustCheckpointID("a1b2c3d4e5f6") - content, err := store.ReadSessionContent(context.Background(), cpID, 0) - require.NoError(t, err, "should be able to read condensed session content") - require.NotNil(t, content) - require.NotNil(t, content.Metadata.InitialAttribution, - "condensed metadata should include attribution") - assert.Positive(t, content.Metadata.InitialAttribution.TotalCommitted, - "attribution TotalCommitted should be non-zero (agent modified test.txt)") -} - // 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. @@ -1240,42 +796,372 @@ func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) { "Second condensation should only contain C.txt and D.txt, not accumulated files from first condensation") } -// setupSessionWithFileChange is like setupSessionWithCheckpoint but also modifies -// test.txt so the shadow branch checkpoint includes actual file changes. -// This enables attribution testing: the diff between base commit and the -// checkpoint/HEAD shows real line changes. -func setupSessionWithFileChange(t *testing.T, s *ManualCommitStrategy, _ *git.Repository, dir, sessionID string) { - t.Helper() +// 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"}, + }, + } - // Modify test.txt to simulate agent work (adds lines relative to initial commit) - testFile := filepath.Join(dir, "test.txt") - require.NoError(t, os.WriteFile(testFile, []byte("initial content\nagent added line\n"), 0o644)) + 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) + }) + } +} - // Create metadata directory with a transcript file +// 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) + + changed := filesChangedInCommit(commit) + 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) + + changed := filesChangedInCommit(commit) + assert.Contains(t, changed, "init.txt") + assert.Len(t, changed, 1) +} + +// 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 := ".entire/metadata/" + sessionID metadataDirAbs := filepath.Join(dir, metadataDir) require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) - transcript := `{"type":"human","message":{"content":"test prompt"}} -{"type":"assistant","message":{"content":"test response"}} + 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)) - // SaveChanges creates the shadow branch and checkpoint - err := s.SaveChanges(SaveContext{ + // 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.SaveChanges(SaveContext{ SessionID: sessionID, - ModifiedFiles: []string{"test.txt"}, - NewFiles: []string{}, + ModifiedFiles: []string{}, + NewFiles: []string{"A.txt", "B.txt", "C.txt"}, DeletedFiles: []string{}, MetadataDir: metadataDir, MetadataDirAbs: metadataDirAbs, - CommitMessage: "Checkpoint 1", + CommitMessage: "Checkpoint: files A, B, C", AuthorName: "Test", AuthorEmail: "test@test.com", }) - require.NoError(t, err, "SaveChanges should succeed to create shadow branch content") + require.NoError(t, err) + + // Set phase to ACTIVE (agent mid-turn) + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(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() + require.NoError(t, err) + + // Verify session stayed ACTIVE + state, err = s.loadSessionState(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 := ".entire/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.SaveChanges(SaveContext{ + 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(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(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() + require.NoError(t, err) + + // Verify session stayed ACTIVE + state, err = s.loadSessionState(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(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + state.TurnCheckpointIDs = nil // Start clean + require.NoError(t, s.saveSessionState(state)) + + // Create first commit with checkpoint trailer + commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") + + err = s.PostCommit() + require.NoError(t, err) + + // Verify TurnCheckpointIDs was populated + state, err = s.loadSessionState(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 + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseIdle + require.NoError(t, s.saveSessionState(state)) + + commitWithCheckpointTrailer(t, repo, dir, "c3d4e5f6a1b2") + + err = s.PostCommit() + require.NoError(t, err) + + // Verify TurnCheckpointIDs was NOT set (IDLE sessions don't need finalization) + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Empty(t, state.TurnCheckpointIDs, + "TurnCheckpointIDs should not be populated for IDLE sessions") } // setupSessionWithCheckpoint initializes a session and creates one checkpoint diff --git a/cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go b/cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go index 377708122..8d6637b90 100644 --- a/cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go +++ b/cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go @@ -4,17 +4,11 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/checkpoint" - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/trailers" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -51,11 +45,11 @@ func TestPrepareCommitMsg_AmendPreservesExistingTrailer(t *testing.T) { "trailer should preserve the original checkpoint ID") } -// TestPrepareCommitMsg_AmendRestoresTrailerFromPendingCheckpointID verifies the amend +// TestPrepareCommitMsg_AmendRestoresTrailerFromLastCheckpointID verifies the amend // bug fix: when a user does `git commit --amend -m "new message"`, the Entire-Checkpoint // trailer is lost because the new message replaces the old one. PrepareCommitMsg restores -// the trailer from PendingCheckpointID in session state. -func TestPrepareCommitMsg_AmendRestoresTrailerFromPendingCheckpointID(t *testing.T) { +// the trailer from LastCheckpointID in session state. +func TestPrepareCommitMsg_AmendRestoresTrailerFromLastCheckpointID(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) @@ -65,11 +59,11 @@ func TestPrepareCommitMsg_AmendRestoresTrailerFromPendingCheckpointID(t *testing err := s.InitializeSession(sessionID, agent.AgentTypeClaudeCode, "", "") require.NoError(t, err) - // Simulate state after condensation: PendingCheckpointID is set + // Simulate state after condensation: LastCheckpointID is set state, err := s.loadSessionState(sessionID) require.NoError(t, err) require.NotNil(t, state) - state.PendingCheckpointID = "abc123def456" + state.LastCheckpointID = id.CheckpointID("abc123def456") err = s.saveSessionState(state) require.NoError(t, err) @@ -82,21 +76,21 @@ func TestPrepareCommitMsg_AmendRestoresTrailerFromPendingCheckpointID(t *testing err = s.PrepareCommitMsg(commitMsgFile, "commit") require.NoError(t, err) - // Read the file back - trailer should be restored from PendingCheckpointID + // Read the file back - trailer should be restored from LastCheckpointID content, err := os.ReadFile(commitMsgFile) require.NoError(t, err) cpID, found := trailers.ParseCheckpoint(string(content)) assert.True(t, found, - "trailer should be restored from PendingCheckpointID on amend") + "trailer should be restored from LastCheckpointID on amend") assert.Equal(t, "abc123def456", cpID.String(), - "restored trailer should use PendingCheckpointID value") + "restored trailer should use LastCheckpointID value") } -// TestPrepareCommitMsg_AmendNoTrailerNoPendingID verifies that when amending with -// no existing trailer and no PendingCheckpointID in session state, no trailer is added. +// TestPrepareCommitMsg_AmendNoTrailerNoLastCheckpointID verifies that when amending with +// no existing trailer and no LastCheckpointID in session state, no trailer is added. // This is the case where the session has never been condensed yet. -func TestPrepareCommitMsg_AmendNoTrailerNoPendingID(t *testing.T) { +func TestPrepareCommitMsg_AmendNoTrailerNoLastCheckpointID(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) @@ -106,11 +100,11 @@ func TestPrepareCommitMsg_AmendNoTrailerNoPendingID(t *testing.T) { err := s.InitializeSession(sessionID, agent.AgentTypeClaudeCode, "", "") require.NoError(t, err) - // Verify PendingCheckpointID is empty (default) + // Verify LastCheckpointID is empty (default) state, err := s.loadSessionState(sessionID) require.NoError(t, err) require.NotNil(t, state) - assert.Empty(t, state.PendingCheckpointID, "PendingCheckpointID should be empty by default") + assert.Empty(t, state.LastCheckpointID, "LastCheckpointID should be empty by default") // Write a commit message file with NO trailer commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") @@ -127,177 +121,9 @@ func TestPrepareCommitMsg_AmendNoTrailerNoPendingID(t *testing.T) { _, found := trailers.ParseCheckpoint(string(content)) assert.False(t, found, - "no trailer should be added when PendingCheckpointID is empty") + "no trailer should be added when LastCheckpointID is empty") // Message should be unchanged assert.Equal(t, newMsg, string(content), "commit message should be unchanged when no trailer to restore") } - -// TestPrepareCommitMsg_NormalCommitUsesPendingCheckpointID verifies that during -// a normal commit (source=""), if the session is in ACTIVE_COMMITTED phase with -// a PendingCheckpointID, the pending ID is reused instead of generating a new one. -// This ensures idempotent checkpoint IDs across prepare-commit-msg invocations. -func TestPrepareCommitMsg_NormalCommitUsesPendingCheckpointID(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - s := &ManualCommitStrategy{} - - sessionID := "test-session-normal-pending" - err := s.InitializeSession(sessionID, agent.AgentTypeClaudeCode, "", "") - require.NoError(t, err) - - // Create content on the shadow branch so filterSessionsWithNewContent finds it - createShadowBranchWithTranscript(t, dir, sessionID) - - // Set the session to ACTIVE_COMMITTED with a PendingCheckpointID - state, err := s.loadSessionState(sessionID) - require.NoError(t, err) - require.NotNil(t, state) - state.Phase = session.PhaseActiveCommitted - state.PendingCheckpointID = "fedcba987654" - // Ensure StepCount reflects that a checkpoint exists on the shadow branch - state.StepCount = 1 - err = s.saveSessionState(state) - require.NoError(t, err) - - // Write a commit message file with no trailer (normal editor flow) - commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") - normalMsg := "Feature: add new functionality\n" - require.NoError(t, os.WriteFile(commitMsgFile, []byte(normalMsg), 0o644)) - - // Call PrepareCommitMsg with source="" (normal commit, editor flow) - err = s.PrepareCommitMsg(commitMsgFile, "") - require.NoError(t, err) - - // Read the file back - trailer should use PendingCheckpointID - content, err := os.ReadFile(commitMsgFile) - require.NoError(t, err) - - cpID, found := trailers.ParseCheckpoint(string(content)) - assert.True(t, found, - "trailer should be present for normal commit with active session content") - assert.Equal(t, "fedcba987654", cpID.String(), - "normal commit should reuse PendingCheckpointID instead of generating a new one") -} - -// createShadowBranchWithTranscript creates a shadow branch commit with a minimal -// transcript file so that filterSessionsWithNewContent detects new content. -// This uses low-level go-git plumbing to create the branch directly. -func createShadowBranchWithTranscript(t *testing.T, repoDir string, sessionID string) { - t.Helper() - - repo, err := git.PlainOpen(repoDir) - require.NoError(t, err) - - head, err := repo.Head() - require.NoError(t, err) - baseCommit := head.Hash().String() - - // Build the tree with a transcript file at the expected path - metadataDir := paths.EntireMetadataDir + "/" + sessionID - transcriptPath := metadataDir + "/" + paths.TranscriptFileName - transcriptContent := `{"type":"message","role":"assistant","content":"hello"}` + "\n" - - // Create blob for transcript - blobObj := &plumbing.MemoryObject{} - blobObj.SetType(plumbing.BlobObject) - blobObj.SetSize(int64(len(transcriptContent))) - writer, err := blobObj.Writer() - require.NoError(t, err) - _, err = writer.Write([]byte(transcriptContent)) - require.NoError(t, err) - require.NoError(t, writer.Close()) - - blobHash, err := repo.Storer.SetEncodedObject(blobObj) - require.NoError(t, err) - - // Build nested tree structure: .entire/metadata//full.jsonl - // We need to build trees bottom-up - innerTree := object.Tree{ - Entries: []object.TreeEntry{ - {Name: paths.TranscriptFileName, Mode: 0o100644, Hash: blobHash}, - }, - } - innerTreeObj := repo.Storer.NewEncodedObject() - innerTreeObj.SetType(plumbing.TreeObject) - require.NoError(t, innerTree.Encode(innerTreeObj)) - innerTreeHash, err := repo.Storer.SetEncodedObject(innerTreeObj) - require.NoError(t, err) - - // Build .entire/metadata/ level - sessionTree := object.Tree{ - Entries: []object.TreeEntry{ - {Name: sessionID, Mode: 0o040000, Hash: innerTreeHash}, - }, - } - sessionTreeObj := repo.Storer.NewEncodedObject() - sessionTreeObj.SetType(plumbing.TreeObject) - require.NoError(t, sessionTree.Encode(sessionTreeObj)) - sessionTreeHash, err := repo.Storer.SetEncodedObject(sessionTreeObj) - require.NoError(t, err) - - // Build .entire/metadata level - metadataTree := object.Tree{ - Entries: []object.TreeEntry{ - {Name: "metadata", Mode: 0o040000, Hash: sessionTreeHash}, - }, - } - metadataTreeObj := repo.Storer.NewEncodedObject() - metadataTreeObj.SetType(plumbing.TreeObject) - require.NoError(t, metadataTree.Encode(metadataTreeObj)) - metadataTreeHash, err := repo.Storer.SetEncodedObject(metadataTreeObj) - require.NoError(t, err) - - // Build .entire level - entireTree := object.Tree{ - Entries: []object.TreeEntry{ - {Name: ".entire", Mode: 0o040000, Hash: metadataTreeHash}, - }, - } - entireTreeObj := repo.Storer.NewEncodedObject() - entireTreeObj.SetType(plumbing.TreeObject) - require.NoError(t, entireTree.Encode(entireTreeObj)) - entireTreeHash, err := repo.Storer.SetEncodedObject(entireTreeObj) - require.NoError(t, err) - - // Create commit on shadow branch - now := time.Now() - commitObj := &object.Commit{ - Author: object.Signature{ - Name: "Test", - Email: "test@test.com", - When: now, - }, - Committer: object.Signature{ - Name: "Test", - Email: "test@test.com", - When: now, - }, - Message: "checkpoint\n\nEntire-Metadata: " + metadataDir + "\nEntire-Session: " + sessionID + "\nEntire-Strategy: manual-commit\n", - TreeHash: entireTreeHash, - } - commitEnc := repo.Storer.NewEncodedObject() - require.NoError(t, commitObj.Encode(commitEnc)) - commitHash, err := repo.Storer.SetEncodedObject(commitEnc) - require.NoError(t, err) - - // Create the shadow branch reference - // WorktreeID is empty for main worktree, which matches what setupGitRepo creates - shadowBranchName := checkpoint.ShadowBranchNameForCommit(baseCommit, "") - refName := plumbing.NewBranchReferenceName(shadowBranchName) - ref := plumbing.NewHashReference(refName, commitHash) - require.NoError(t, repo.Storer.SetReference(ref)) - - // Verify the transcript is readable - verifyCommit, err := repo.CommitObject(commitHash) - require.NoError(t, err) - verifyTree, err := verifyCommit.Tree() - require.NoError(t, err) - file, err := verifyTree.File(transcriptPath) - require.NoError(t, err, "transcript file should exist at %s", transcriptPath) - content, err := file.Contents() - require.NoError(t, err) - require.NotEmpty(t, content, "transcript should have content") -} diff --git a/cmd/entire/cli/strategy/phase_wiring_test.go b/cmd/entire/cli/strategy/phase_wiring_test.go index 6795ec03c..7e8be5265 100644 --- a/cmd/entire/cli/strategy/phase_wiring_test.go +++ b/cmd/entire/cli/strategy/phase_wiring_test.go @@ -33,6 +33,8 @@ func TestInitializeSession_SetsPhaseActive(t *testing.T) { "InitializeSession should set phase to ACTIVE") require.NotNil(t, state.LastInteractionTime, "InitializeSession should set LastInteractionTime") + assert.NotEmpty(t, state.TurnID, + "InitializeSession should set TurnID") } // TestInitializeSession_IdleToActive verifies a second call (existing IDLE session) @@ -136,35 +138,6 @@ func TestInitializeSession_EndedToActive(t *testing.T) { require.NotNil(t, state.LastInteractionTime) } -// TestInitializeSession_ActiveCommittedToActive verifies Ctrl-C recovery -// after a mid-session commit: ACTIVE_COMMITTED → ACTIVE. -func TestInitializeSession_ActiveCommittedToActive(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - - s := &ManualCommitStrategy{} - - // First call initializes - err := s.InitializeSession("test-session-ac-recovery", "Claude Code", "", "") - require.NoError(t, err) - - // Manually set to ACTIVE_COMMITTED - state, err := s.loadSessionState("test-session-ac-recovery") - require.NoError(t, err) - state.Phase = session.PhaseActiveCommitted - err = s.saveSessionState(state) - require.NoError(t, err) - - // Call InitializeSession again - should transition ACTIVE_COMMITTED → ACTIVE - err = s.InitializeSession("test-session-ac-recovery", "Claude Code", "", "") - require.NoError(t, err) - - state, err = s.loadSessionState("test-session-ac-recovery") - require.NoError(t, err) - assert.Equal(t, session.PhaseActive, state.Phase, - "should transition from ACTIVE_COMMITTED to ACTIVE") -} - // TestInitializeSession_EmptyPhaseBackwardCompat verifies that sessions // without a Phase field (pre-state-machine) get treated as IDLE → ACTIVE. func TestInitializeSession_EmptyPhaseBackwardCompat(t *testing.T) { diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 37fdc79c4..2d5fde9f7 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -458,12 +458,12 @@ type PrePushHandler interface { } // TurnEndHandler is an optional interface for strategies that need to -// handle deferred actions when an agent turn ends. -// For example, manual-commit strategy uses this to condense session data -// that was deferred during ACTIVE_COMMITTED → IDLE transitions. +// handle actions when an agent turn ends. +// For example, manual-commit strategy uses this to handle any remaining +// strategy-specific actions from the ACTIVE → IDLE transition. type TurnEndHandler interface { // HandleTurnEnd dispatches strategy-specific actions emitted by the - // ACTIVE_COMMITTED → IDLE (or other) turn-end transition. + // turn-end transition (e.g., ACTIVE → IDLE). // The state has already been updated by ApplyCommonActions; the caller // saves it after this method returns. HandleTurnEnd(state *session.State, actions []session.Action) error From a99e7b8163048912b12e63efb57129127715cd99 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 16:35:46 +0100 Subject: [PATCH 05/22] local review feedback Entire-Checkpoint: 332f7c352062 --- cmd/entire/cli/checkpoint/checkpoint.go | 3 ++ cmd/entire/cli/checkpoint/committed.go | 27 ++++++++++----- cmd/entire/cli/hooks_claudecode_handlers.go | 17 +++++----- cmd/entire/cli/session/phase.go | 7 ++-- cmd/entire/cli/session/phase_test.go | 2 +- cmd/entire/cli/strategy/auto_commit.go | 1 + .../cli/strategy/manual_commit_hooks.go | 33 +++++++++++++++---- .../cli/strategy/phase_postcommit_test.go | 4 +-- cmd/entire/cli/strategy/strategy.go | 16 ++++----- docs/KNOWN_LIMITATIONS.md | 2 +- 10 files changed, 72 insertions(+), 40 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index f6457d6b8..79d8d9a05 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -311,6 +311,9 @@ type UpdateCommittedOptions struct { // Context is the updated context.md content (replaces existing) Context []byte + + // Agent identifies the agent type (needed for transcript chunking) + Agent agent.AgentType } // CommittedInfo contains summary information about a committed checkpoint. diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index d58882d91..0bd5b8deb 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1075,7 +1075,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti // Replace transcript (full replace, not append) if len(opts.Transcript) > 0 { - if err := s.replaceTranscript(opts.Transcript, sessionPath, entries); err != nil { + if err := s.replaceTranscript(opts.Transcript, opts.Agent, sessionPath, entries); err != nil { return fmt.Errorf("failed to replace transcript: %w", err) } } @@ -1131,7 +1131,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti // replaceTranscript writes the full transcript content, replacing any existing transcript. // Also removes any chunk files from a previous write and updates the content hash. -func (s *GitStore) replaceTranscript(transcript []byte, sessionPath string, entries map[string]object.TreeEntry) error { +func (s *GitStore) replaceTranscript(transcript []byte, agentType agent.AgentType, sessionPath string, entries map[string]object.TreeEntry) error { // Remove existing transcript files (base + any chunks) transcriptBase := sessionPath + paths.TranscriptFileName for key := range entries { @@ -1140,15 +1140,24 @@ func (s *GitStore) replaceTranscript(transcript []byte, sessionPath string, entr } } - // Write new transcript blob - blobHash, err := CreateBlobFromContent(s.repo, transcript) + // Chunk the transcript (matches writeTranscript behavior) + chunks, err := agent.ChunkTranscript(transcript, agentType) if err != nil { - return fmt.Errorf("failed to create transcript blob: %w", err) + return fmt.Errorf("failed to chunk transcript: %w", err) } - entries[transcriptBase] = object.TreeEntry{ - Name: transcriptBase, - Mode: filemode.Regular, - Hash: blobHash, + + // Write chunk files + for i, chunk := range chunks { + chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i) + blobHash, err := CreateBlobFromContent(s.repo, chunk) + if err != nil { + return fmt.Errorf("failed to create transcript blob: %w", err) + } + entries[chunkPath] = object.TreeEntry{ + Name: chunkPath, + Mode: filemode.Regular, + Hash: blobHash, + } } // Update content hash diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index 16d52327a..d496d09e8 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -743,15 +743,14 @@ func transitionSessionTurnEnd(sessionID string) { if turnState == nil { return } - remaining := strategy.TransitionAndLog(turnState, session.EventTurnEnd, session.TransitionContext{}) - - // Dispatch strategy-specific actions if any remain after common handling - if len(remaining) > 0 { - strat := GetStrategy() - if handler, ok := strat.(strategy.TurnEndHandler); ok { - if err := handler.HandleTurnEnd(turnState, remaining); err != nil { - fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) - } + strategy.TransitionAndLog(turnState, session.EventTurnEnd, session.TransitionContext{}) + + // Always dispatch to strategy for turn-end handling. The strategy reads + // work items from state (e.g. TurnCheckpointIDs), not the action list. + strat := GetStrategy() + if handler, ok := strat.(strategy.TurnEndHandler); ok { + if err := handler.HandleTurnEnd(turnState); err != nil { + fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) } } diff --git a/cmd/entire/cli/session/phase.go b/cmd/entire/cli/session/phase.go index 6b291afc0..417aa454b 100644 --- a/cmd/entire/cli/session/phase.go +++ b/cmd/entire/cli/session/phase.go @@ -24,15 +24,16 @@ var allPhases = []Phase{PhaseIdle, PhaseActive, PhaseEnded} // as PhaseIdle for backward compatibility with pre-state-machine session files. func PhaseFromString(s string) Phase { switch Phase(s) { - case PhaseActive: + case PhaseActive, "active_committed": + // "active_committed" was removed but meant "agent active + commit happened". + // Normalize to ACTIVE so HandleTurnEnd can finalize any pending checkpoints. return PhaseActive case PhaseIdle: return PhaseIdle case PhaseEnded: return PhaseEnded default: - // Backward compat: unknown phases (including removed "active_committed") - // normalize to idle. + // Backward compat: truly unknown phases normalize to idle. return PhaseIdle } } diff --git a/cmd/entire/cli/session/phase_test.go b/cmd/entire/cli/session/phase_test.go index 38942a10d..541308497 100644 --- a/cmd/entire/cli/session/phase_test.go +++ b/cmd/entire/cli/session/phase_test.go @@ -17,7 +17,7 @@ func TestPhaseFromString(t *testing.T) { want Phase }{ {name: "active", input: "active", want: PhaseActive}, - {name: "active_committed", input: "active_committed", want: PhaseIdle}, + {name: "active_committed", input: "active_committed", want: PhaseActive}, {name: "idle", input: "idle", want: PhaseIdle}, {name: "ended", input: "ended", want: PhaseEnded}, {name: "empty_string_defaults_to_idle", input: "", want: PhaseIdle}, diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 4be567cf2..4a6c7281e 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -936,6 +936,7 @@ func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent return fmt.Errorf("failed to generate turn ID: %w", err) } existing.TurnID = turnID.String() + existing.TurnCheckpointIDs = nil // Backfill FirstPrompt if empty (for sessions // created before the first_prompt field was added, or resumed sessions) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index b1f837cb6..6a82df8eb 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -19,6 +19,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/entireio/cli/cmd/entire/cli/trailers" + "github.com/entireio/cli/redact" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -561,8 +562,7 @@ func (s *ManualCommitStrategy) PostCommit() error { switch action { case session.ActionCondense: if hasNew { - s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) - condensed = true + condensed = s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) // condenseAndUpdateState updates BaseCommit on success. // On failure, BaseCommit is preserved so the shadow branch remains accessible. } else { @@ -574,8 +574,7 @@ func (s *ManualCommitStrategy) PostCommit() error { // but hasNew is an additional content-level check (transcript has // new content beyond what was previously condensed). if len(state.FilesTouched) > 0 && hasNew { - s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) - condensed = true + condensed = s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) // On failure, BaseCommit is preserved (same as ActionCondense). } else { s.updateBaseCommitIfChanged(logCtx, state, newHead) @@ -655,7 +654,7 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( head *plumbing.Reference, shadowBranchName string, shadowBranchesToDelete map[string]struct{}, -) { +) bool { result, err := s.CondenseSession(repo, checkpointID, state) if err != nil { fmt.Fprintf(os.Stderr, "[entire] Warning: condensation failed for session %s: %v\n", @@ -664,7 +663,7 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( slog.String("session_id", state.SessionID), slog.String("error", err.Error()), ) - return + return false } // Track this shadow branch for cleanup @@ -698,6 +697,8 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( slog.Int("checkpoints_condensed", result.CheckpointsCount), slog.Int("transcript_lines", result.TotalTranscriptLines), ) + + return true } // updateBaseCommitIfChanged updates BaseCommit to newHead if it changed. @@ -1320,7 +1321,7 @@ func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *Sessio // (from prompt to stop event), ensuring every checkpoint has the full context. // //nolint:unparam // error return required by interface but hooks must return nil -func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState, _ []session.Action) error { +func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState) error { // Finalize all checkpoints from this turn with the full transcript. // Best-effort: log warnings but don't fail the hook. s.finalizeAllTurnCheckpoints(state) @@ -1368,6 +1369,23 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { prompts := extractUserPrompts(state.AgentType, string(fullTranscript)) contextBytes := generateContextFromPrompts(prompts) + // Redact secrets before writing — matches WriteCommitted behavior. + // The live transcript on disk contains raw content; redaction must happen + // before anything is persisted to the metadata branch. + fullTranscript, err = redact.JSONLBytes(fullTranscript) + if err != nil { + logging.Warn(logCtx, "finalize: transcript redaction failed, skipping", + slog.String("session_id", state.SessionID), + slog.String("error", err.Error()), + ) + state.TurnCheckpointIDs = nil + return + } + for i, p := range prompts { + prompts[i] = redact.String(p) + } + contextBytes = redact.Bytes(contextBytes) + // Open repository and create checkpoint store repo, err := OpenRepository() if err != nil { @@ -1396,6 +1414,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { Transcript: fullTranscript, Prompts: prompts, Context: contextBytes, + Agent: state.AgentType, }) if updateErr != nil { logging.Warn(logCtx, "finalize: failed to update checkpoint", diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index cc735974d..fa0b3fc67 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -642,8 +642,8 @@ func TestTurnEnd_Active_NoActions(t *testing.T) { assert.Empty(t, remaining, "ACTIVE + TurnEnd should not emit strategy-specific actions") - // Call HandleTurnEnd with empty actions — should be a no-op - err = s.HandleTurnEnd(state, remaining) + // Call HandleTurnEnd — should be a no-op (no TurnCheckpointIDs) + err = s.HandleTurnEnd(state) require.NoError(t, err) // Verify state is unchanged diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 2d5fde9f7..7866a74fc 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -458,15 +458,15 @@ type PrePushHandler interface { } // TurnEndHandler is an optional interface for strategies that need to -// handle actions when an agent turn ends. -// For example, manual-commit strategy uses this to handle any remaining -// strategy-specific actions from the ACTIVE → IDLE transition. +// perform work when an agent turn ends (ACTIVE → IDLE). +// For example, manual-commit strategy uses this to finalize checkpoints +// with the full session transcript. type TurnEndHandler interface { - // HandleTurnEnd dispatches strategy-specific actions emitted by the - // turn-end transition (e.g., ACTIVE → IDLE). - // The state has already been updated by ApplyCommonActions; the caller - // saves it after this method returns. - HandleTurnEnd(state *session.State, actions []session.Action) error + // HandleTurnEnd performs strategy-specific cleanup at the end of a turn. + // Work items are read from state (e.g. TurnCheckpointIDs), not from the + // action list. The state has already been updated by ApplyCommonActions; + // the caller saves it after this method returns. + HandleTurnEnd(state *session.State) error } // RestoredSession describes a single session that was restored by RestoreLogsOnly. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 99ad44d53..32d0dbee4 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -8,7 +8,7 @@ This document describes known limitations of the Entire CLI. When you amend a commit using `git commit --amend -m "new message"`, the `-m` flag replaces the entire message including any `Entire-Checkpoint` trailer. Git passes `source="message"` (not `"commit"`) to the prepare-commit-msg hook, so the amend-specific trailer preservation logic is bypassed. -**However, the trailer is automatically restored** if `PendingCheckpointID` or `LastCheckpointID` exists in session state (set during the original condensation). This means `git commit --amend -m "..."` preserves the checkpoint link in most cases, including when Claude does the amend in a non-interactive environment. +**However, the trailer is automatically restored** if `LastCheckpointID` exists in session state (set during the original condensation). This means `git commit --amend -m "..."` preserves the checkpoint link in most cases, including when Claude does the amend in a non-interactive environment. The only case where the link is lost is when `-m` is used with genuinely *new* content (no prior condensation) and `/dev/tty` is not available for the interactive confirmation prompt. From 7c0d2a83ad2120a306e05cfae0fddc60d9fb2ec1 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 16:57:59 +0100 Subject: [PATCH 06/22] reflect that we now have two commits per checkpoint folder Entire-Checkpoint: c2385b018377 --- CLAUDE.md | 12 ++++++------ docs/architecture/sessions-and-checkpoints.md | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec65318c4..acadaab63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -425,18 +425,18 @@ Both strategies use a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7` **Bidirectional linking:** ``` -User commit → Metadata (two approaches): - Approach 1: Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer - → Look up a3/b2c4d5e6f7/ directory on entire/checkpoints/v1 branch - - Approach 2: Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer - → Search entire/checkpoints/v1 commit history for "Checkpoint: a3b2c4d5e6f7" subject +User commit → Metadata: + Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer + → Read a3/b2c4d5e6f7/ directory from entire/checkpoints/v1 tree at HEAD Metadata → User commits: Given checkpoint ID a3b2c4d5e6f7 → Search user branch history for commits with "Entire-Checkpoint: a3b2c4d5e6f7" trailer ``` +Note: Commit subjects on `entire/checkpoints/v1` (e.g., `Checkpoint: a3b2c4d5e6f7`) are +for human readability in `git log` only. The CLI always reads from the tree at HEAD. + **Example:** ``` User's commit (on main branch): diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index 7b1f30277..d35fe62ff 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -279,15 +279,16 @@ The checkpoint ID is the **stable identifier** that links user commits to metada ``` User commit → Metadata: 1. Extract "Entire-Checkpoint: a3b2c4d5e6f7" from commit message - 2. Look up metadata: - - Approach A: Read entire/checkpoints/v1 tree at a3/b2c4d5e6f7/ - - Approach B: Search git log entire/checkpoints/v1 for "Checkpoint: a3b2c4d5e6f7" + 2. Read entire/checkpoints/v1 tree at a3/b2c4d5e6f7/ Metadata → User commits: Given checkpoint ID a3b2c4d5e6f7 → Search branch history for commits with "Entire-Checkpoint: a3b2c4d5e6f7" ``` +Note: Commit subjects on `entire/checkpoints/v1` (e.g., `Checkpoint: a3b2c4d5e6f7`) +are for human readability in `git log` only. The CLI always reads from the tree at HEAD. + **Example Flow:** ``` From 59e5aba2dbbe2b87c8c3a148648a20a87e6d3c6b Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 17:35:10 +0100 Subject: [PATCH 07/22] and more minor fixes, should be good now Entire-Checkpoint: 7b9bb14c5953 --- cmd/entire/cli/session/state.go | 4 +++- cmd/entire/cli/strategy/auto_commit.go | 7 +++++++ cmd/entire/cli/strategy/manual_commit_hooks.go | 11 ++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index bde795713..47ca535fe 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -95,7 +95,9 @@ type State struct { // FilesTouched tracks files modified/created/deleted during this session FilesTouched []string `json:"files_touched,omitempty"` - // LastCheckpointID is the checkpoint ID from last condensation, reused for subsequent commits without new content + // LastCheckpointID is the checkpoint ID from the most recent condensation. + // Used to restore the Entire-Checkpoint trailer on amend and to identify + // sessions that have been condensed at least once. Cleared on new prompt. LastCheckpointID id.CheckpointID `json:"last_checkpoint_id,omitempty"` // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor") diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 4a6c7281e..489145988 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -249,6 +249,12 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository // Combine all file changes into FilesTouched (same as manual-commit) filesTouched := mergeFilesTouched(nil, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles) + // Load TurnID from session state (correlates checkpoints from the same turn) + var turnID string + if state, loadErr := LoadSessionState(sessionID); loadErr == nil && state != nil { + turnID = state.TurnID + } + // Write committed checkpoint using the checkpoint store err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: checkpointID, @@ -259,6 +265,7 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository AuthorName: ctx.AuthorName, AuthorEmail: ctx.AuthorEmail, Agent: ctx.AgentType, + TurnID: turnID, TranscriptIdentifierAtStart: ctx.StepTranscriptIdentifier, CheckpointTranscriptStart: ctx.StepTranscriptStart, TokenUsage: ctx.TokenUsage, diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 6a82df8eb..5e72658ce 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -593,16 +593,13 @@ func (s *ManualCommitStrategy) PostCommit() error { } } - // Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize with full transcript. - // Only ACTIVE sessions need finalization — IDLE/ENDED sessions already have complete transcripts. + // For ACTIVE sessions that were condensed: + // 1. Record checkpoint ID so HandleTurnEnd can finalize with full transcript + // (IDLE/ENDED sessions already have complete transcripts) + // 2. Carry forward remaining uncommitted files so the next commit gets its own checkpoint if condensed && state.Phase.IsActive() { state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String()) - } - // After condensation, carry forward remaining uncommitted files for ACTIVE sessions. - // This ensures that if the agent touched 3 files but only 2 were committed, - // the third file still has a shadow branch so the next commit gets its own checkpoint. - if condensed && state.Phase.IsActive() { remainingFiles := subtractFiles(filesTouchedBefore, committedFileSet) if len(remainingFiles) > 0 { s.carryForwardToNewShadowBranch(logCtx, repo, state, remainingFiles) From 0419f1be6c1bc3a6cfb90ddaa051422ca9e923e4 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 20:26:14 +0100 Subject: [PATCH 08/22] better handling of multi manual commit Entire-Checkpoint: 0bce1f68b21e --- .../cli/strategy/manual_commit_hooks.go | 33 +++++++++++++++---- .../cli/strategy/phase_postcommit_test.go | 29 ++++++++++++---- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 5e72658ce..57cea5854 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -561,12 +561,20 @@ func (s *ManualCommitStrategy) PostCommit() error { for _, action := range remaining { switch action { case session.ActionCondense: - if hasNew { + // For ACTIVE sessions, any commit during the turn is session-related. + // For IDLE/ENDED sessions (e.g., carry-forward), also require that the + // committed files overlap with the session's remaining files — otherwise + // an unrelated commit would incorrectly get this session's checkpoint. + shouldCondense := hasNew + if shouldCondense && !state.Phase.IsActive() { + shouldCondense = filesOverlap(committedFileSet, state.FilesTouched) + } + if shouldCondense { condensed = s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) // condenseAndUpdateState updates BaseCommit on success. // On failure, BaseCommit is preserved so the shadow branch remains accessible. } else { - // No new content to condense — just update BaseCommit + // No new content or unrelated commit — just update BaseCommit s.updateBaseCommitIfChanged(logCtx, state, newHead) } case session.ActionCondenseIfFilesTouched: @@ -593,13 +601,16 @@ func (s *ManualCommitStrategy) PostCommit() error { } } - // For ACTIVE sessions that were condensed: - // 1. Record checkpoint ID so HandleTurnEnd can finalize with full transcript - // (IDLE/ENDED sessions already have complete transcripts) - // 2. Carry forward remaining uncommitted files so the next commit gets its own checkpoint + // Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize + // with full transcript. IDLE/ENDED sessions already have complete transcripts. if 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. + if condensed { remainingFiles := subtractFiles(filesTouchedBefore, committedFileSet) if len(remainingFiles) > 0 { s.carryForwardToNewShadowBranch(logCtx, repo, state, remainingFiles) @@ -1433,6 +1444,16 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { state.TurnCheckpointIDs = nil } +// filesOverlap checks if any file in the committed set appears in filesTouched. +func filesOverlap(committed map[string]struct{}, filesTouched []string) bool { + for _, f := range filesTouched { + if _, ok := committed[f]; ok { + return true + } + } + return false +} + // hasOverlappingFiles checks if any file in stagedFiles appears in filesTouched. func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { touchedSet := make(map[string]bool) diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index fa0b3fc67..607873c55 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -86,6 +86,7 @@ func TestPostCommit_IdleSession_Condenses(t *testing.T) { require.NoError(t, err) state.Phase = session.PhaseIdle state.LastInteractionTime = nil + state.FilesTouched = []string{"test.txt"} require.NoError(t, s.saveSessionState(state)) // Record shadow branch name before PostCommit @@ -200,6 +201,7 @@ func TestPostCommit_ShadowBranch_PreservedWhenUncondensedActiveSessionExists(t * // Set idle session to IDLE phase idleState.Phase = session.PhaseIdle idleState.LastInteractionTime = nil + idleState.FilesTouched = []string{"test.txt"} require.NoError(t, s.saveSessionState(idleState)) // Create a second session with the SAME base commit and worktree (concurrent session). @@ -274,6 +276,7 @@ func TestPostCommit_CondensationFailure_PreservesShadowBranch(t *testing.T) { require.NoError(t, err) state.Phase = session.PhaseIdle state.LastInteractionTime = nil + state.FilesTouched = []string{"test.txt"} require.NoError(t, s.saveSessionState(state)) // Record original BaseCommit and StepCount before corruption @@ -713,9 +716,9 @@ func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) { assert.ElementsMatch(t, []string{"A.txt", "B.txt"}, state.FilesTouched, "FilesTouched should contain A.txt and B.txt before first condensation") - // --- Commit and condense (round 1) --- + // --- Commit A.txt, B.txt and condense (round 1) --- checkpointID1 := "a1a2a3a4a5a6" - commitWithCheckpointTrailer(t, repo, dir, checkpointID1) + commitFilesWithTrailer(t, repo, dir, checkpointID1, "A.txt", "B.txt") err = s.PostCommit() require.NoError(t, err) @@ -737,7 +740,7 @@ func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) { state, err = s.loadSessionState(sessionID) require.NoError(t, err) assert.Nil(t, state.FilesTouched, - "FilesTouched should be nil after condensation") + "FilesTouched should be nil after condensation (all files were committed)") // --- Round 2: Save checkpoint touching files C.txt and D.txt --- @@ -780,9 +783,9 @@ func TestPostCommit_FilesTouched_ResetsAfterCondensation(t *testing.T) { assert.ElementsMatch(t, []string{"C.txt", "D.txt"}, state.FilesTouched, "FilesTouched should only contain C.txt and D.txt after reset") - // --- Commit and condense (round 2) --- + // --- Commit C.txt, D.txt and condense (round 2) --- checkpointID2 := "b1b2b3b4b5b6" - commitWithCheckpointTrailer(t, repo, dir, checkpointID2) + commitFilesWithTrailer(t, repo, dir, checkpointID2, "C.txt", "D.txt") err = s.PostCommit() require.NoError(t, err) @@ -1146,10 +1149,11 @@ func TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs(t *testing.T) { setupSessionWithCheckpoint(t, s, repo, dir, sessionID) - // Set phase to IDLE + // Set phase to IDLE with files touched so overlap check passes state, err := s.loadSessionState(sessionID) require.NoError(t, err) state.Phase = session.PhaseIdle + state.FilesTouched = []string{"test.txt"} require.NoError(t, s.saveSessionState(state)) commitWithCheckpointTrailer(t, repo, dir, "c3d4e5f6a1b2") @@ -1201,10 +1205,17 @@ func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Re // 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. A test.txt is also touched to ensure there's always something to commit. +func commitFilesWithTrailer(t *testing.T, repo *git.Repository, dir, checkpointIDStr string, files ...string) { + t.Helper() cpID := id.MustCheckpointID(checkpointIDStr) - // Modify a file so there is something to commit + // Always touch test.txt so the commit is never empty testFile := filepath.Join(dir, "test.txt") content := "updated at " + time.Now().String() require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644)) @@ -1214,6 +1225,10 @@ func commitWithCheckpointTrailer(t *testing.T, repo *git.Repository, dir, checkp _, 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{ From 6a3b681443d1b6e601f0666ae1d38cbba8e25972 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 22:53:10 +0100 Subject: [PATCH 09/22] more tests and content aware overlap check Entire-Checkpoint: ca5ddaabc88a --- .../deferred_finalization_test.go | 825 ++++++++++++++++++ .../strategy/manual_commit_condensation.go | 118 ++- .../cli/strategy/manual_commit_hooks.go | 499 ++++++++++- 3 files changed, 1407 insertions(+), 35 deletions(-) create mode 100644 cmd/entire/cli/integration_test/deferred_finalization_test.go diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go new file mode 100644 index 000000000..509b3b3de --- /dev/null +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -0,0 +1,825 @@ +//go:build integration + +package integration + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// TestShadow_DeferredTranscriptFinalization tests that HandleTurnEnd updates +// the provisional transcript (written at commit time) with the full transcript +// (available at turn end). +// +// Flow: +// 1. Agent starts working (ACTIVE) +// 2. Agent makes file changes +// 3. User commits while agent is ACTIVE → provisional transcript condensed +// 4. Agent continues work (updates transcript) +// 5. Agent finishes (SimulateStop) → transcript finalized via UpdateCommitted +// +// This verifies that the final transcript on entire/checkpoints/v1 includes +// work done AFTER the commit. +func TestShadow_DeferredTranscriptFinalization(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Helper to submit with transcript path (needed for mid-session commit detection) + submitWithTranscriptPath := func(sessionID, transcriptPath string) { + t.Helper() + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + inputJSON, _ := json.Marshal(input) + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") + cmd.Dir = env.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) + } + } + + // Start session (ACTIVE) + submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + + state, err := env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state.Phase != session.PhaseActive { + t.Errorf("Expected ACTIVE phase, got %s", state.Phase) + } + + // Create file and initial 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"}, + }) + + // Debug: verify session state before commit + preCommitState, _ := env.GetSessionState(sess.ID) + if preCommitState == nil { + t.Fatal("Session state should exist before commit") + } + t.Logf("Pre-commit session state: phase=%s, worktreePath=%s, baseCommit=%s", + preCommitState.Phase, preCommitState.WorktreePath, preCommitState.BaseCommit[:7]) + + // User commits while agent is still ACTIVE + // This triggers condensation with the provisional transcript + // Using custom commit with verbose output for debugging + { + env.GitAdd("feature.go") + msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG") + if err := os.WriteFile(msgFile, []byte("Add feature"), 0o644); err != nil { + t.Fatalf("failed to write commit message: %v", err) + } + + // Run prepare-commit-msg + prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "message") + prepCmd.Dir = env.RepoDir + prepCmd.Env = append(os.Environ(), "ENTIRE_TEST_TTY=1") + prepOutput, prepErr := prepCmd.CombinedOutput() + t.Logf("prepare-commit-msg output: %s (err: %v)", prepOutput, prepErr) + + // Read modified message + modifiedMsg, _ := os.ReadFile(msgFile) + t.Logf("Commit message after prepare-commit-msg: %s", modifiedMsg) + + // Create commit + repo, _ := git.PlainOpen(env.RepoDir) + worktree, _ := repo.Worktree() + _, err := worktree.Commit(string(modifiedMsg), &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Run post-commit + postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit") + postCmd.Dir = env.RepoDir + postOutput, postErr := postCmd.CombinedOutput() + t.Logf("post-commit output: %s (err: %v)", postOutput, postErr) + } + commitHash := env.GetHeadHash() + + checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash) + if checkpointID == "" { + t.Fatal("Commit should have checkpoint trailer") + } + t.Logf("Checkpoint ID after mid-session commit: %s", checkpointID) + + // Debug: verify session state after commit + postCommitState, _ := env.GetSessionState(sess.ID) + if postCommitState != nil { + t.Logf("Post-commit session state: phase=%s, baseCommit=%s, turnCheckpointIDs=%v", + postCommitState.Phase, postCommitState.BaseCommit[:7], postCommitState.TurnCheckpointIDs) + } else { + t.Log("Post-commit session state is nil (shouldn't happen)") + } + + // Debug: list all branches + branches := env.ListBranchesWithPrefix("") + t.Logf("All branches after commit: %v", branches) + + // Verify checkpoint exists on metadata branch (provisional) + if !env.BranchExists(paths.MetadataBranchName) { + t.Fatal("entire/checkpoints/v1 branch should exist") + } + + // Read the provisional transcript + transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) + provisionalContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) + if !found { + t.Fatalf("Provisional transcript should exist at %s", transcriptPath) + } + t.Logf("Provisional transcript length: %d bytes", len(provisionalContent)) + + // Verify session state has TurnCheckpointIDs for deferred finalization + state, err = env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if len(state.TurnCheckpointIDs) == 0 { + t.Error("TurnCheckpointIDs should contain the checkpoint ID for finalization") + } + + // Agent continues work - add more to transcript + sess.TranscriptBuilder.AddUserMessage("Also add a helper function") + sess.TranscriptBuilder.AddAssistantMessage("Adding helper function now") + toolID := sess.TranscriptBuilder.AddToolUse("mcp__acp__Write", "helper.go", "package main\n\nfunc Helper() {}\n") + sess.TranscriptBuilder.AddToolResult(toolID) + sess.TranscriptBuilder.AddAssistantMessage("Done with both changes!") + + // Write updated transcript + if err := sess.TranscriptBuilder.WriteToFile(sess.TranscriptPath); err != nil { + t.Fatalf("Failed to write updated transcript: %v", err) + } + + // Also create the file in worktree (for consistency, though not committed yet) + env.WriteFile("helper.go", "package main\n\nfunc Helper() {}\n") + + // Agent finishes turn - this triggers HandleTurnEnd which should finalize the transcript + if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Read the finalized transcript + finalContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) + if !found { + t.Fatalf("Finalized transcript should exist at %s", transcriptPath) + } + t.Logf("Finalized transcript length: %d bytes", len(finalContent)) + + // The finalized transcript should be longer (include post-commit work) + if len(finalContent) <= len(provisionalContent) { + t.Errorf("Finalized transcript should be longer than provisional.\n"+ + "Provisional: %d bytes\nFinalized: %d bytes", + len(provisionalContent), len(finalContent)) + } + + // Verify the finalized transcript contains the additional work + if !strings.Contains(finalContent, "Also add a helper function") { + t.Error("Finalized transcript should contain post-commit user message") + } + if !strings.Contains(finalContent, "helper.go") { + t.Error("Finalized transcript should contain helper.go tool use") + } + + // Verify session is now IDLE + 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 after stop, got %s", state.Phase) + } + + // TurnCheckpointIDs should be cleared after finalization + if len(state.TurnCheckpointIDs) != 0 { + t.Errorf("TurnCheckpointIDs should be cleared after finalization, got %v", state.TurnCheckpointIDs) + } + + t.Log("DeferredTranscriptFinalization test completed successfully") +} + +// TestShadow_CarryForward_ActiveSession tests that when a user commits only +// some of the files touched by an ACTIVE session, the remaining files are +// carried forward to a new shadow branch. +// +// Flow: +// 1. Agent touches files A, B, C while ACTIVE +// 2. User commits only file A → checkpoint #1 +// 3. Session remains ACTIVE with files B, C pending +// 4. User commits file B → checkpoint #2 (new checkpoint ID) +func TestShadow_CarryForward_ActiveSession(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Helper to submit with transcript path + submitWithTranscriptPath := func(sessionID, transcriptPath string) { + t.Helper() + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + inputJSON, _ := json.Marshal(input) + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") + cmd.Dir = env.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) + } + } + + // Start session (ACTIVE) + submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + + // Create multiple files + 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") + + // Create transcript with all files + 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"}, + }) + + // Verify session is ACTIVE + state, err := env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state.Phase != session.PhaseActive { + t.Errorf("Expected ACTIVE phase, got %s", state.Phase) + } + + // First commit: only file A + env.GitCommitWithShadowHooks("Add file A", "fileA.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) + + // Session should still be ACTIVE (mid-turn commit) + state, err = env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state.Phase != session.PhaseActive { + t.Errorf("Expected ACTIVE phase after partial commit, got %s", state.Phase) + } + t.Logf("After first commit: FilesTouched=%v, CheckpointTranscriptStart=%d, BaseCommit=%s, TurnCheckpointIDs=%v", + state.FilesTouched, state.CheckpointTranscriptStart, state.BaseCommit[:7], state.TurnCheckpointIDs) + + // List branches to see if shadow branch was created + branches := env.ListBranchesWithPrefix("entire/") + t.Logf("Entire branches after first commit: %v", branches) + + // Stage file B to see what the commit would include + env.GitAdd("fileB.go") + + // Second commit: file B (should get a NEW checkpoint ID) + env.GitCommitWithShadowHooks("Add file B", "fileB.go") + secondCommitHash := env.GetHeadHash() + secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash) + if secondCheckpointID == "" { + t.Fatal("Second commit should have checkpoint trailer") + } + t.Logf("Second checkpoint ID: %s", secondCheckpointID) + + // CRITICAL: Each commit gets its own unique checkpoint ID + if firstCheckpointID == secondCheckpointID { + t.Errorf("Each commit should get a unique checkpoint ID.\n"+ + "First: %s\nSecond: %s", + firstCheckpointID, secondCheckpointID) + } + + // Verify both checkpoints exist on metadata branch + firstPath := CheckpointSummaryPath(firstCheckpointID) + if !env.FileExistsInBranch(paths.MetadataBranchName, firstPath) { + t.Errorf("First checkpoint metadata should exist at %s", firstPath) + } + + secondPath := CheckpointSummaryPath(secondCheckpointID) + if !env.FileExistsInBranch(paths.MetadataBranchName, secondPath) { + t.Errorf("Second checkpoint metadata should exist at %s", secondPath) + } + + t.Log("CarryForward_ActiveSession test completed successfully") +} + +// TestShadow_CarryForward_IdleSession tests that when a user commits only +// some of the files touched during an IDLE session, subsequent commits +// for remaining files can still get checkpoint trailers. +// +// Flow: +// 1. Agent touches files A and B, then stops (IDLE) +// 2. User commits only file A → checkpoint #1 +// 3. Session is IDLE, but still has file B pending +// 4. User commits file B → checkpoint #2 (if carry-forward for IDLE is implemented) +// or no trailer (if IDLE sessions don't carry forward) +func TestShadow_CarryForward_IdleSession(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session + if err := env.SimulateUserPromptSubmit(sess.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create multiple 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 (becomes 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) + } + + // First commit: only file A + env.GitCommitWithShadowHooks("Add file A", "fileA.go") + firstCommitHash := env.GetHeadHash() + firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash) + if firstCheckpointID == "" { + t.Fatal("First commit should have checkpoint trailer (IDLE session, files overlap)") + } + t.Logf("First checkpoint ID: %s", firstCheckpointID) + + // Second commit: file B + // In the 1:1 model, this should also get a checkpoint if IDLE sessions + // carry forward, or no trailer if they don't. + env.GitCommitWithShadowHooks("Add file B", "fileB.go") + secondCommitHash := env.GetHeadHash() + secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash) + + if secondCheckpointID != "" { + // If carry-forward is implemented for IDLE sessions + t.Logf("Second checkpoint ID: %s (carry-forward active)", secondCheckpointID) + if firstCheckpointID == secondCheckpointID { + t.Error("If both commits have trailers, they must have DIFFERENT checkpoint IDs") + } + } else { + // If IDLE sessions don't carry forward (current behavior) + t.Log("Second commit has no checkpoint trailer (IDLE sessions don't carry forward)") + } + + t.Log("CarryForward_IdleSession test completed successfully") +} + +// TestShadow_MultipleCommits_SameActiveTurn tests that multiple commits +// during a single ACTIVE turn each get unique checkpoint IDs, and all +// are finalized when the turn ends. +// +// Flow: +// 1. Agent starts working (ACTIVE) +// 2. User commits file A → checkpoint #1 (provisional) +// 3. User commits file B → checkpoint #2 (provisional) +// 4. User commits file C → checkpoint #3 (provisional) +// 5. Agent finishes (SimulateStop) → all 3 checkpoints finalized +func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Helper to submit with transcript path + submitWithTranscriptPath := func(sessionID, transcriptPath string) { + t.Helper() + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + inputJSON, _ := json.Marshal(input) + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") + cmd.Dir = env.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) + } + } + + // Start session (ACTIVE) + submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + + // Create multiple files + 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"}, + }) + + // Commit each file separately while ACTIVE + checkpointIDs := make([]string, 3) + + env.GitCommitWithShadowHooks("Add file A", "fileA.go") + checkpointIDs[0] = env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if checkpointIDs[0] == "" { + t.Fatal("First commit should have checkpoint trailer") + } + + env.GitCommitWithShadowHooks("Add file B", "fileB.go") + checkpointIDs[1] = env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if checkpointIDs[1] == "" { + t.Fatal("Second commit should have checkpoint trailer") + } + + env.GitCommitWithShadowHooks("Add file C", "fileC.go") + checkpointIDs[2] = env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if checkpointIDs[2] == "" { + t.Fatal("Third commit should have checkpoint trailer") + } + + t.Logf("Checkpoint IDs: %v", checkpointIDs) + + // 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 + } + + // Verify TurnCheckpointIDs contains all 3 + state, err := env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if len(state.TurnCheckpointIDs) != 3 { + t.Errorf("TurnCheckpointIDs should have 3 entries, got %d: %v", + len(state.TurnCheckpointIDs), state.TurnCheckpointIDs) + } + + // Add more work to transcript before stopping + sess.TranscriptBuilder.AddAssistantMessage("All files created successfully!") + if err := sess.TranscriptBuilder.WriteToFile(sess.TranscriptPath); err != nil { + t.Fatalf("Failed to write transcript: %v", err) + } + + // Agent finishes - this should finalize ALL checkpoints + if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Verify session is IDLE + 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) + } + + // TurnCheckpointIDs should be cleared after finalization + if len(state.TurnCheckpointIDs) != 0 { + t.Errorf("TurnCheckpointIDs should be cleared, got %v", state.TurnCheckpointIDs) + } + + // Verify all checkpoints exist and have finalized transcripts + for i, cpID := range checkpointIDs { + transcriptPath := SessionFilePath(cpID, paths.TranscriptFileName) + content, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) + if !found { + t.Errorf("Checkpoint %d transcript should exist at %s", i, transcriptPath) + continue + } + // All transcripts should contain the final message + if !strings.Contains(content, "All files created successfully") { + t.Errorf("Checkpoint %d transcript should be finalized with final message", i) + } + } + + t.Log("MultipleCommits_SameActiveTurn test completed successfully") +} + +// TestShadow_OverlapCheck_UnrelatedCommit tests that commits for files NOT +// touched by the session don't get checkpoint trailers (when session is not ACTIVE). +// +// Flow: +// 1. Agent touches file A, then stops (IDLE) +// 2. User commits file A → checkpoint (files overlap with session) +// 3. Session BaseCommit updated, FilesTouched cleared +// 4. User creates file B manually (not through session) +// 5. User commits file B → NO checkpoint (no overlap with session) +func TestShadow_OverlapCheck_UnrelatedCommit(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session + if err := env.SimulateUserPromptSubmit(sess.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create file A through session + env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n") + sess.CreateTranscript("Create file A", []FileChange{ + {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"}, + }) + + // Stop session (becomes IDLE) + if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Commit file A - should get checkpoint (overlaps with session) + env.GitCommitWithShadowHooks("Add file A", "fileA.go") + firstCommitHash := env.GetHeadHash() + firstCheckpointID := env.GetCheckpointIDFromCommitMessage(firstCommitHash) + if firstCheckpointID == "" { + t.Fatal("First commit should have checkpoint trailer (files overlap)") + } + t.Logf("First checkpoint ID: %s", firstCheckpointID) + + // Create file B manually (not through session) + env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n") + + // Commit file B - should NOT get checkpoint (no overlap with session files) + env.GitCommitWithShadowHooks("Add file B (manual)", "fileB.go") + secondCommitHash := env.GetHeadHash() + secondCheckpointID := env.GetCheckpointIDFromCommitMessage(secondCommitHash) + + if secondCheckpointID != "" { + t.Errorf("Second commit should NOT have checkpoint trailer "+ + "(file B not touched by session), got %s", secondCheckpointID) + } else { + t.Log("Second commit correctly has no checkpoint trailer (no overlap)") + } + + t.Log("OverlapCheck_UnrelatedCommit test completed successfully") +} + +// TestShadow_OverlapCheck_PartialOverlap tests that commits with SOME files +// from the session get checkpoint trailers, even if they include other files. +// +// Flow: +// 1. Agent touches file A, then stops (IDLE) +// 2. User creates file B manually +// 3. User commits both A and B → checkpoint (partial overlap is enough) +func TestShadow_OverlapCheck_PartialOverlap(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session + if err := env.SimulateUserPromptSubmit(sess.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create file A through session + env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n") + sess.CreateTranscript("Create file A", []FileChange{ + {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"}, + }) + + // Stop session (becomes IDLE) + if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Create file B manually (not through session) + env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n") + + // Commit both files together - should get checkpoint (partial overlap is enough) + env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go") + commitHash := env.GetHeadHash() + checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash) + + if checkpointID == "" { + t.Error("Commit should have checkpoint trailer (file A overlaps with session)") + } else { + t.Logf("Checkpoint ID: %s (partial overlap triggered checkpoint)", checkpointID) + } + + 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, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session + if err := env.SimulateUserPromptSubmit(sess.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit 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, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session + if err := env.SimulateUserPromptSubmit(sess.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit 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") +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 8f34a2b8f..82dd921f0 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -107,25 +107,39 @@ func (s *ManualCommitStrategy) getCheckpointLog(checkpointID id.CheckpointID) ([ // checkpointID is the 12-hex-char value from the Entire-Checkpoint trailer. // Metadata is stored at sharded path: // // Uses checkpoint.GitStore.WriteCommitted for the git operations. +// +// For mid-session commits (no Stop/SaveChanges called yet), the shadow branch may not exist. +// In this case, data is extracted from the live transcript instead. func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointID id.CheckpointID, state *SessionState) (*CondenseResult, error) { - // Get shadow branch + // Get shadow branch (may not exist for mid-session commits) shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("shadow branch not found: %w", err) - } - - // Extract session data from the shadow branch (with live transcript fallback). - // Use tracked files from session state instead of collecting all files from tree. - // Pass agent type to handle different transcript formats (JSONL for Claude, JSON for Gemini). - // Pass live transcript path so condensation reads the current file rather than a - // potentially stale shadow branch copy (SaveChanges may have been skipped if the - // last turn had no code changes). - // Pass CheckpointTranscriptStart for accurate token calculation (line offset for Claude, message index for Gemini). - sessionData, err := s.extractSessionData(repo, ref.Hash(), state.SessionID, state.FilesTouched, state.AgentType, state.TranscriptPath, state.CheckpointTranscriptStart) - if err != nil { - return nil, fmt.Errorf("failed to extract session data: %w", err) + hasShadowBranch := err == nil + + var sessionData *ExtractedSessionData + if hasShadowBranch { + // Extract session data from the shadow branch (with live transcript fallback). + // Use tracked files from session state instead of collecting all files from tree. + // Pass agent type to handle different transcript formats (JSONL for Claude, JSON for Gemini). + // Pass live transcript path so condensation reads the current file rather than a + // potentially stale shadow branch copy (SaveChanges may have been skipped if the + // last turn had no code changes). + // Pass CheckpointTranscriptStart for accurate token calculation (line offset for Claude, message index for Gemini). + sessionData, err = s.extractSessionData(repo, ref.Hash(), state.SessionID, state.FilesTouched, state.AgentType, state.TranscriptPath, state.CheckpointTranscriptStart) + if err != nil { + return nil, fmt.Errorf("failed to extract session data: %w", err) + } + } else { + // No shadow branch: mid-session commit before Stop/SaveChanges. + // Extract data directly from live transcript. + if state.TranscriptPath == "" { + return nil, errors.New("shadow branch not found and no live transcript available") + } + sessionData, err = s.extractSessionDataFromLiveTranscript(state) + if err != nil { + return nil, fmt.Errorf("failed to extract session data from live transcript: %w", err) + } } // Get checkpoint store @@ -136,7 +150,11 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI // Get author info authorName, authorEmail := GetGitAuthorFromRepo(repo) - attribution := calculateSessionAttributions(repo, ref, sessionData, state) + // Attribution calculation requires shadow branch reference; skip if mid-session commit + var attribution *cpkg.InitialAttribution + if hasShadowBranch { + attribution = calculateSessionAttributions(repo, ref, sessionData, state) + } // Get current branch name branchName := GetCurrentBranchName(repo) @@ -362,6 +380,74 @@ func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRe 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(state *SessionState) (*ExtractedSessionData, error) { + data := &ExtractedSessionData{} + + // Read the live transcript + if state.TranscriptPath == "" { + return nil, errors.New("no transcript path in session state") + } + + liveData, err := os.ReadFile(state.TranscriptPath) + 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 = extractUserPrompts(state.AgentType, fullTranscript) + data.Context = generateContextFromPrompts(data.Prompts) + + // Extract files from transcript since state.FilesTouched may be empty for mid-session commits + // (no SaveChanges/Stop has been called yet to populate it) + if len(state.FilesTouched) > 0 { + data.FilesTouched = state.FilesTouched + } else { + // Extract modified files from transcript + ag, agErr := agent.GetByAgentType(state.AgentType) + if agErr == nil { + if analyzer, ok := ag.(agent.TranscriptAnalyzer); ok { + modifiedFiles, _, extractErr := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, state.CheckpointTranscriptStart) + if extractErr == nil && len(modifiedFiles) > 0 { + // Normalize to repo-relative paths + basePath := state.WorktreePath + if basePath == "" { + if wp, wpErr := GetWorktreePath(); 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, rel) + } else { + normalized = append(normalized, f) + } + } + modifiedFiles = normalized + } + data.FilesTouched = modifiedFiles + } + } + } + } + + // Calculate token usage from the extracted transcript portion + if len(data.Transcript) > 0 { + data.TokenUsage = calculateTokenUsage(state.AgentType, data.Transcript, state.CheckpointTranscriptStart) + } + + 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 and JSON-based agents, this counts messages. diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 57cea5854..a64964406 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -463,7 +463,7 @@ func (s *ManualCommitStrategy) handleAmendCommitMsg(logCtx context.Context, comm // and were condensed during this PostCommit. // During rebase/cherry-pick/revert operations, phase transitions are skipped entirely. // -//nolint:unparam // error return required by interface but hooks must return nil +//nolint:unparam,maintidx // error return required by interface but hooks must return nil; maintidx: complex but already well-structured func (s *ManualCommitStrategy) PostCommit() error { logCtx := logging.WithComponent(context.Background(), "checkpoint") @@ -533,13 +533,36 @@ func (s *ManualCommitStrategy) PostCommit() error { // 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. - hasNew, contentErr := s.sessionHasNewContent(repo, state) - 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()), - ) + // + // For ACTIVE sessions: the commit has a checkpoint trailer (verified above), + // meaning PrepareCommitMsg already determined this commit is session-related. + // We trust that and assume hasNew = true, bypassing sessionHasNewContent which + // would incorrectly return false (uses getStagedFiles, but files are no longer + // staged after the commit). + var hasNew bool + if state.Phase.IsActive() { + // For ACTIVE sessions, check if this session has any content to condense. + // A session with no checkpoints (StepCount=0) and no files touched may exist + // concurrently with other sessions that DO have content. + // We only condense if this session actually has work. + if state.StepCount > 0 || len(state.FilesTouched) > 0 { + hasNew = true + } else { + // No checkpoints and no tracked files - check the live transcript. + // Use sessionHasNewContentInCommittedFiles because staged files are empty + // after the commit (files have already been committed). + hasNew = s.sessionHasNewContentInCommittedFiles(state, committedFileSet) + } + } else { + var contentErr error + hasNew, contentErr = s.sessionHasNewContent(repo, state) + 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 @@ -548,8 +571,21 @@ func (s *ManualCommitStrategy) PostCommit() error { // Save FilesTouched BEFORE the action loop — condensation clears it, // but we need the original list for carry-forward computation. + // For mid-session commits (ACTIVE, no shadow branch), state.FilesTouched may be empty + // because no SaveChanges/Stop has been called yet. Extract files from transcript. filesTouchedBefore := make([]string, len(state.FilesTouched)) copy(filesTouchedBefore, state.FilesTouched) + if len(filesTouchedBefore) == 0 && state.Phase.IsActive() && state.TranscriptPath != "" { + filesTouchedBefore = s.extractFilesFromLiveTranscript(state) + } + + 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), + ) condensed := false @@ -563,11 +599,12 @@ func (s *ManualCommitStrategy) PostCommit() error { case session.ActionCondense: // For ACTIVE sessions, any commit during the turn is session-related. // For IDLE/ENDED sessions (e.g., carry-forward), also require that the - // committed files overlap with the session's remaining files — otherwise - // an unrelated commit would incorrectly get this session's checkpoint. + // committed files overlap with the session's remaining files AND have + // matching content — otherwise an unrelated commit (or a commit with + // completely replaced content) would incorrectly get this session's checkpoint. shouldCondense := hasNew if shouldCondense && !state.Phase.IsActive() { - shouldCondense = filesOverlap(committedFileSet, state.FilesTouched) + shouldCondense = filesOverlapWithContent(repo, shadowBranchName, commit, state.FilesTouched) } if shouldCondense { condensed = s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) @@ -612,6 +649,13 @@ func (s *ManualCommitStrategy) PostCommit() error { // commit across two `git commit` invocations, each gets a 1:1 checkpoint. if condensed { remainingFiles := subtractFiles(filesTouchedBefore, committedFileSet) + logging.Debug(logCtx, "post-commit: carry-forward decision", + 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), + ) if len(remainingFiles) > 0 { s.carryForwardToNewShadowBranch(logCtx, repo, state, remainingFiles) } @@ -814,17 +858,34 @@ func (s *ManualCommitStrategy) sessionHasNewContent(repo *git.Repository, state // Look for transcript file metadataDir := paths.EntireMetadataDir + "/" + state.SessionID var transcriptLines int + var hasTranscriptFile bool if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileName); fileErr == nil { + hasTranscriptFile = true if content, contentErr := file.Contents(); contentErr == nil { transcriptLines = countTranscriptItems(state.AgentType, content) } } else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil { + hasTranscriptFile = true if content, contentErr := file.Contents(); contentErr == nil { transcriptLines = countTranscriptItems(state.AgentType, content) } } + // 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). + stagedFiles := getStagedFiles(repo) + result := stagedFilesOverlapWithContent(repo, tree, stagedFiles, state.FilesTouched) + return result, nil + } + // No transcript and no FilesTouched - fall back to live transcript check + return s.sessionHasNewContentFromLiveTranscript(repo, state) + } + // Has new content if there are more lines than already condensed return transcriptLines > state.CheckpointTranscriptStart, nil } @@ -946,6 +1007,175 @@ func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(repo *git. return true, nil } +// sessionHasNewContentInCommittedFiles checks if a session has content that overlaps with +// the committed files. This is used in PostCommit for ACTIVE sessions where staged files +// are empty (already committed). Uses the live transcript to extract modified files and +// compares against the committed file set. +func (s *ManualCommitStrategy) sessionHasNewContentInCommittedFiles(state *SessionState, committedFiles map[string]struct{}) bool { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Need both transcript path and agent type to analyze + if state.TranscriptPath == "" || state.AgentType == "" { + logging.Debug(logCtx, "committed files check: missing transcript path or agent type", + slog.String("session_id", state.SessionID), + slog.String("transcript_path", state.TranscriptPath), + slog.String("agent_type", string(state.AgentType)), + ) + return false + } + + // Get the agent for transcript analysis + ag, err := agent.GetByAgentType(state.AgentType) + if err != nil { + return false // Unknown agent type, fail gracefully + } + + // Cast to TranscriptAnalyzer + analyzer, ok := ag.(agent.TranscriptAnalyzer) + if !ok { + return false // Agent doesn't support transcript analysis + } + + // Get current transcript position + currentPos, err := analyzer.GetTranscriptPosition(state.TranscriptPath) + if err != nil { + return false // Error reading transcript, fail gracefully + } + + // Check if transcript has grown since last condensation + if currentPos <= state.CheckpointTranscriptStart { + logging.Debug(logCtx, "committed files check: no new content", + slog.String("session_id", state.SessionID), + slog.Int("current_pos", currentPos), + slog.Int("start_offset", state.CheckpointTranscriptStart), + ) + return false // No new content + } + + // Transcript has grown - check if there are file modifications in the new portion + modifiedFiles, _, err := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, state.CheckpointTranscriptStart) + if err != nil { + return false // Error parsing transcript, fail gracefully + } + + // No file modifications means no new content to checkpoint + if len(modifiedFiles) == 0 { + logging.Debug(logCtx, "committed files check: transcript grew but no file modifications", + slog.String("session_id", state.SessionID), + ) + return false + } + + // Normalize modified files from absolute to repo-relative paths. + basePath := state.WorktreePath + if basePath == "" { + if wp, wpErr := GetWorktreePath(); 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, rel) + } else { + normalized = append(normalized, f) + } + } + modifiedFiles = normalized + } + + logging.Debug(logCtx, "committed files check: found file modifications", + slog.String("session_id", state.SessionID), + slog.Int("modified_files", len(modifiedFiles)), + slog.Int("committed_files", len(committedFiles)), + ) + + // Check if any modified files overlap with committed files + for _, f := range modifiedFiles { + if _, ok := committedFiles[f]; ok { + return true + } + } + + logging.Debug(logCtx, "committed files check: no overlap between committed and modified files", + slog.String("session_id", state.SessionID), + ) + return false +} + +// extractFilesFromLiveTranscript extracts modified file paths from the live transcript. +// Returns empty slice if extraction fails (fail-open behavior for hooks). +// Extracts ALL files from the transcript (offset 0) because this is used for carry-forward +// computation which needs to know all files touched, not just new ones. +func (s *ManualCommitStrategy) extractFilesFromLiveTranscript(state *SessionState) []string { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + if state.TranscriptPath == "" || state.AgentType == "" { + logging.Debug(logCtx, "extractFilesFromLiveTranscript: missing path or agent type", + slog.String("transcript_path", state.TranscriptPath), + slog.String("agent_type", string(state.AgentType)), + ) + return nil + } + + ag, err := agent.GetByAgentType(state.AgentType) + if err != nil { + logging.Debug(logCtx, "extractFilesFromLiveTranscript: agent not found", + slog.String("agent_type", string(state.AgentType)), + slog.String("error", err.Error()), + ) + return nil + } + + analyzer, ok := ag.(agent.TranscriptAnalyzer) + if !ok { + logging.Debug(logCtx, "extractFilesFromLiveTranscript: agent is not a TranscriptAnalyzer", + slog.String("agent_type", string(state.AgentType)), + ) + return nil + } + + // Extract ALL files from transcript (offset 0) for carry-forward computation. + // state.CheckpointTranscriptStart may already be updated after condensation, + // but carry-forward needs to know all files touched to compute remaining files. + modifiedFiles, _, err := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, 0) + if err != nil || len(modifiedFiles) == 0 { + logging.Debug(logCtx, "extractFilesFromLiveTranscript: no files extracted", + slog.String("transcript_path", state.TranscriptPath), + slog.Int("files_count", len(modifiedFiles)), + slog.Any("error", err), + ) + return nil + } + + logging.Debug(logCtx, "extractFilesFromLiveTranscript: files extracted", + slog.Int("files_count", len(modifiedFiles)), + slog.Any("files", modifiedFiles), + ) + + // Normalize to repo-relative paths + basePath := state.WorktreePath + if basePath == "" { + if wp, wpErr := GetWorktreePath(); 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, rel) + } else { + normalized = append(normalized, f) + } + } + modifiedFiles = normalized + } + + return modifiedFiles +} + // 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. @@ -1444,13 +1674,243 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { state.TurnCheckpointIDs = nil } -// filesOverlap checks if any file in the committed set appears in filesTouched. -func filesOverlap(committed map[string]struct{}, filesTouched []string) bool { +// filesOverlapWithContent checks if any file in the committed set overlaps with +// filesTouched AND has matching content in the shadow branch. +// +// This prevents linking commits where the user reverted session changes and wrote +// completely different content. Only files whose committed content matches the +// shadow branch content (by hash) are considered true overlaps. +// +// Falls back to filename-only check if shadow branch is not accessible. +func filesOverlapWithContent(repo *git.Repository, shadowBranchName string, headCommit *object.Commit, filesTouched []string) bool { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Build set of filesTouched for quick lookup + touchedSet := make(map[string]bool) for _, f := range filesTouched { - if _, ok := committed[f]; ok { + touchedSet[f] = true + } + + // Get HEAD commit tree (the committed content) + headTree, err := headCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get HEAD tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 // Fall back: assume overlap if any files touched + } + + // Get shadow branch tree (the session's content) + refName := plumbing.NewBranchReferenceName(shadowBranchName) + shadowRef, err := repo.Reference(refName, true) + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: shadow branch not found, falling back to filename check", + slog.String("branch", shadowBranchName), + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 // Fall back: assume overlap if any files touched + } + + shadowCommit, err := repo.CommitObject(shadowRef.Hash()) + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow commit, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 + } + + shadowTree, err := shadowCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 + } + + // Get the parent commit tree to determine if files are modified vs newly created. + // For modified files (exist in parent), we count as overlap regardless of content + // because the user is editing the session's work. + // For newly created files (don't exist in parent), we check content to detect + // the "reverted and replaced" scenario where user deleted session's work and + // created something completely different. + var parentTree *object.Tree + if headCommit.NumParents() > 0 { + if parent, err := headCommit.Parent(0); err == nil { + if pTree, err := parent.Tree(); err == nil { + parentTree = pTree + } + } + } + + // Check each file in filesTouched + for _, filePath := range filesTouched { + // Get file from HEAD tree + headFile, err := headTree.File(filePath) + if err != nil { + // File not in HEAD commit - doesn't count as overlap + continue + } + + // Check if this is a modified file (exists in parent) or new file + isModified := false + if parentTree != nil { + if _, err := parentTree.File(filePath); err == nil { + isModified = true + } + } + + // Modified files always count as overlap (user edited session's work) + if isModified { + logging.Debug(logCtx, "filesOverlapWithContent: modified file counts as overlap", + slog.String("file", filePath), + ) return true } + + // For new files, check content against shadow branch + shadowFile, err := shadowTree.File(filePath) + if err != nil { + // File not in shadow branch - this shouldn't happen but skip it + logging.Debug(logCtx, "filesOverlapWithContent: file in filesTouched but not in shadow branch", + slog.String("file", filePath), + ) + continue + } + + // Compare by hash (blob hash) - exact content match required for new files + if headFile.Hash == shadowFile.Hash { + logging.Debug(logCtx, "filesOverlapWithContent: new file content match found", + slog.String("file", filePath), + slog.String("hash", headFile.Hash.String()), + ) + return true + } + + logging.Debug(logCtx, "filesOverlapWithContent: new file content mismatch (may be reverted & replaced)", + slog.String("file", filePath), + slog.String("head_hash", headFile.Hash.String()), + slog.String("shadow_hash", shadowFile.Hash.String()), + ) + } + + logging.Debug(logCtx, "filesOverlapWithContent: no overlapping files found", + slog.Int("files_checked", len(filesTouched)), + ) + return false +} + +// stagedFilesOverlapWithContent checks if any staged file overlaps with filesTouched, +// distinguishing between modified files (always overlap) and new files (check content). +// +// For modified files (already exist in HEAD), we count as overlap because the user +// is editing the session's work. For new files (don't exist in HEAD), we require +// content match to detect the "reverted and replaced" scenario. +// +// This is used in PrepareCommitMsg for carry-forward scenarios. +func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree, stagedFiles, filesTouched []string) bool { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Build set of filesTouched for quick lookup + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + touchedSet[f] = true + } + + // Get HEAD tree to determine if files are being modified or newly created + head, err := repo.Head() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD commit, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + headTree, err := headCommit.Tree() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) } + + // Get the git index to access staged file hashes + idx, err := repo.Storer.Index() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get index, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + + // Check each staged file + for _, stagedPath := range stagedFiles { + if !touchedSet[stagedPath] { + continue // Not in filesTouched, skip + } + + // Check if this is a modified file (exists in HEAD) or new file + _, headErr := headTree.File(stagedPath) + isModified := headErr == nil + + // Modified files always count as overlap (user edited session's work) + if isModified { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: modified file counts as overlap", + slog.String("file", stagedPath), + ) + return true + } + + // For new files, check content against shadow branch + // Find the index entry to get the staged file's hash + var stagedHash plumbing.Hash + found := false + for _, entry := range idx.Entries { + if entry.Name == stagedPath { + stagedHash = entry.Hash + found = true + break + } + } + if !found { + continue // Not in index (shouldn't happen but be safe) + } + + // Get file from shadow branch tree + shadowFile, err := shadowTree.File(stagedPath) + if err != nil { + // File not in shadow branch - doesn't count as content match + logging.Debug(logCtx, "stagedFilesOverlapWithContent: file not in shadow tree", + slog.String("file", stagedPath), + ) + continue + } + + // Compare hashes - for new files, require exact content match + if stagedHash == shadowFile.Hash { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content match found", + slog.String("file", stagedPath), + slog.String("hash", stagedHash.String()), + ) + return true + } + + logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content mismatch (may be reverted & replaced)", + slog.String("file", stagedPath), + slog.String("staged_hash", stagedHash.String()), + slog.String("shadow_hash", shadowFile.Hash.String()), + ) + } + + logging.Debug(logCtx, "stagedFilesOverlapWithContent: no overlapping files found", + slog.Int("staged_files", len(stagedFiles)), + slog.Int("files_touched", len(filesTouched)), + ) return false } @@ -1537,16 +1997,17 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( ) { store := checkpoint.NewGitStore(repo) - metadataDir := paths.SessionMetadataDirFromSessionID(state.SessionID) - metadataDirAbs := filepath.Join(state.WorktreePath, metadataDir) - + // 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. result, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ SessionID: state.SessionID, BaseCommit: state.BaseCommit, WorktreeID: state.WorktreeID, ModifiedFiles: remainingFiles, - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, + MetadataDir: "", + MetadataDirAbs: "", CommitMessage: "carry forward: uncommitted session files", IsFirstCheckpoint: false, }) From e78caaabf0879cc3fa180ab0570f4ae70544a8b0 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 23:19:16 +0100 Subject: [PATCH 10/22] more test coverage / documentation Entire-Checkpoint: 93fa19e317c5 --- .../cli/strategy/content_overlap_test.go | 288 ++++++++++++++++++ .../cli/strategy/manual_commit_hooks.go | 18 +- 2 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 cmd/entire/cli/strategy/content_overlap_test.go diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go new file mode 100644 index 000000000..ac8633e06 --- /dev/null +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -0,0 +1,288 @@ +package strategy + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFilesOverlapWithContent_ModifiedFile tests that a modified file (exists in parent) +// counts as overlap regardless of content changes. +func TestFilesOverlapWithContent_ModifiedFile(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create initial file and commit + testFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(testFile, []byte("original content"), 0o644)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("test.txt") + require.NoError(t, err) + _, err = wt.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // Create shadow branch with same file content as session created + sessionContent := []byte("session modified content") + createShadowBranchWithContent(t, repo, "abc1234", "e3b0c4", map[string][]byte{ + "test.txt": sessionContent, + }) + + // Modify the file with DIFFERENT content (user edited session's work) + require.NoError(t, os.WriteFile(testFile, []byte("user modified further"), 0o644)) + _, err = wt.Add("test.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Modify file", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // Get HEAD commit + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + // Test: Modified file should count as overlap even with different content + shadowBranch := checkpoint.ShadowBranchNameForCommit("abc1234", "e3b0c4") + result := filesOverlapWithContent(repo, shadowBranch, commit, []string{"test.txt"}) + assert.True(t, result, "Modified file should count as overlap (user edited session's work)") +} + +// TestFilesOverlapWithContent_NewFile_ContentMatch tests that a new file with +// matching content counts as overlap. +func TestFilesOverlapWithContent_NewFile_ContentMatch(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create shadow branch with a new file + originalContent := []byte("session created this content") + createShadowBranchWithContent(t, repo, "def5678", "e3b0c4", map[string][]byte{ + "newfile.txt": originalContent, + }) + + // Commit the same file with SAME content (user commits session's work unchanged) + testFile := filepath.Join(dir, "newfile.txt") + require.NoError(t, os.WriteFile(testFile, originalContent, 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("newfile.txt") + require.NoError(t, err) + headCommit, 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(headCommit) + require.NoError(t, err) + + // Test: New file with matching content should count as overlap + shadowBranch := checkpoint.ShadowBranchNameForCommit("def5678", "e3b0c4") + result := filesOverlapWithContent(repo, shadowBranch, commit, []string{"newfile.txt"}) + assert.True(t, result, "New file with matching content should count as overlap") +} + +// TestFilesOverlapWithContent_NewFile_ContentMismatch tests that a new file with +// completely different content does NOT count as overlap (reverted & replaced scenario). +func TestFilesOverlapWithContent_NewFile_ContentMismatch(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create shadow branch with a file + sessionContent := []byte("session created this") + createShadowBranchWithContent(t, repo, "ghi9012", "e3b0c4", map[string][]byte{ + "replaced.txt": sessionContent, + }) + + // Commit a file with COMPLETELY DIFFERENT content (user reverted & replaced) + testFile := filepath.Join(dir, "replaced.txt") + require.NoError(t, os.WriteFile(testFile, []byte("user wrote something totally unrelated"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("replaced.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Add replaced file", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + // Test: New file with different content should NOT count as overlap + shadowBranch := checkpoint.ShadowBranchNameForCommit("ghi9012", "e3b0c4") + result := filesOverlapWithContent(repo, shadowBranch, commit, []string{"replaced.txt"}) + assert.False(t, result, "New file with different content should NOT count as overlap (reverted & replaced)") +} + +// TestFilesOverlapWithContent_FileNotInCommit tests that a file in filesTouched +// but not in the commit doesn't count as overlap. +func TestFilesOverlapWithContent_FileNotInCommit(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create shadow branch with files + fileAContent := []byte("file A content") + fileBContent := []byte("file B content") + createShadowBranchWithContent(t, repo, "jkl3456", "e3b0c4", map[string][]byte{ + "fileA.txt": fileAContent, + "fileB.txt": fileBContent, + }) + + // Only commit fileA (not fileB) + fileA := filepath.Join(dir, "fileA.txt") + require.NoError(t, os.WriteFile(fileA, fileAContent, 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("fileA.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Add only file A", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + // Test: Only fileB in filesTouched, which is not in commit + shadowBranch := checkpoint.ShadowBranchNameForCommit("jkl3456", "e3b0c4") + result := filesOverlapWithContent(repo, shadowBranch, commit, []string{"fileB.txt"}) + assert.False(t, result, "File not in commit should not count as overlap") + + // Test: fileA in filesTouched and in commit - should overlap (new file with matching content) + result = filesOverlapWithContent(repo, shadowBranch, commit, []string{"fileA.txt"}) + assert.True(t, result, "File in commit with matching content should count as overlap") +} + +// TestFilesOverlapWithContent_NoShadowBranch tests fallback when shadow branch doesn't exist. +func TestFilesOverlapWithContent_NoShadowBranch(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create a commit without any shadow branch + testFile := filepath.Join(dir, "test.txt") + 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) + headCommit, err := wt.Commit("Test commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + // Test: Non-existent shadow branch should fall back to assuming overlap + result := filesOverlapWithContent(repo, "entire/nonexistent-e3b0c4", commit, []string{"test.txt"}) + assert.True(t, result, "Missing shadow branch should fall back to assuming overlap") +} + +// createShadowBranchWithContent creates a shadow branch with the given file contents. +// This helper directly uses go-git APIs to avoid paths.RepoRoot() 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(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) +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index a64964406..0c880fe23 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1011,6 +1011,14 @@ func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(repo *git. // the committed files. This is used in PostCommit for ACTIVE sessions where staged files // are empty (already committed). Uses the live transcript to extract modified files and // compares against the committed file set. +// +// KNOWN LIMITATION: This function relies on transcript analysis to detect file modifications. +// If the agent makes file modifications via shell commands (e.g., `sed`, `mv`, `cp`) that +// aren't captured in the transcript's file modification tracking, those modifications may +// not be detected. This is an acceptable edge case because: +// 1. Most agent file modifications use the Write/Edit tools which are tracked +// 2. Shell-based modifications are relatively rare in practice +// 3. The consequence (missing a checkpoint trailer) is minor - the transcript is still saved func (s *ManualCommitStrategy) sessionHasNewContentInCommittedFiles(state *SessionState, committedFiles map[string]struct{}) bool { logCtx := logging.WithComponent(context.Background(), "checkpoint") @@ -2026,8 +2034,14 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( } // Update state for the carry-forward checkpoint. - // CheckpointTranscriptStart = 0 is intentional: prompt-level carry-forward means - // the next condensation re-processes the full transcript so the checkpoint is self-contained. + // 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.FilesTouched = remainingFiles state.StepCount = 1 state.CheckpointTranscriptStart = 0 From 26c3570a7519beb2bdd0cd997601627ef9cd0ee6 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 13 Feb 2026 23:28:08 +0100 Subject: [PATCH 11/22] extracted content aware logic, more logging Entire-Checkpoint: 7cf71da0278c --- cmd/entire/cli/session/state.go | 14 + cmd/entire/cli/strategy/content_overlap.go | 281 +++++++++++++++++ .../cli/strategy/manual_commit_hooks.go | 288 ++---------------- 3 files changed, 321 insertions(+), 262 deletions(-) create mode 100644 cmd/entire/cli/strategy/content_overlap.go diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 47ca535fe..2eb1b6393 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -13,6 +14,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/validation" ) @@ -159,6 +161,18 @@ type PromptAttribution struct { // NormalizeAfterLoad applies backward-compatible migrations to state loaded from disk. // Call this after deserializing a State from JSON. func (s *State) NormalizeAfterLoad() { + // Normalize legacy phase values. "active_committed" was removed in favor of + // the state machine handling commits during ACTIVE phase. + if s.Phase == "active_committed" { + logCtx := logging.WithComponent(context.Background(), "session") + logging.Info(logCtx, "migrating legacy active_committed phase to active", + slog.String("session_id", s.SessionID), + ) + s.Phase = PhaseActive + } + // Also normalize via PhaseFromString to handle any other legacy/unknown values. + s.Phase = PhaseFromString(string(s.Phase)) + // Migrate transcript fields: CheckpointTranscriptStart replaces both // CondensedTranscriptLines and TranscriptLinesAtStart from older state files. if s.CheckpointTranscriptStart == 0 { diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go new file mode 100644 index 000000000..bdbf41697 --- /dev/null +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -0,0 +1,281 @@ +package strategy + +import ( + "context" + "log/slog" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// Content-aware overlap detection for checkpoint management. +// +// These functions determine whether a commit contains session-related work by comparing +// file content (not just filenames) against the shadow branch. This enables accurate +// detection of the "reverted and replaced" scenario where a user: +// 1. Reverts session changes (e.g., git checkout -- file.txt) +// 2. Creates completely different content in the same file +// 3. Commits the new content +// +// In this scenario, the commit should NOT get a checkpoint trailer because the +// session's work was discarded, not incorporated. +// +// The key distinction: +// - Modified files (exist in parent commit): Always count as overlap, regardless of +// content changes. The user is editing session's work. +// - New files (don't exist in parent): Require content match against shadow branch. +// If content differs completely, the session's work was likely reverted & replaced. + +// filesOverlapWithContent checks if any file in filesTouched overlaps with the committed +// content, using content-aware comparison to detect the "reverted and replaced" scenario. +// +// This is used in PostCommit to determine if a session has work in the commit. +func filesOverlapWithContent(repo *git.Repository, shadowBranchName string, headCommit *object.Commit, filesTouched []string) bool { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Build set of filesTouched for quick lookup + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + touchedSet[f] = true + } + + // Get HEAD commit tree (the committed content) + headTree, err := headCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get HEAD tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 // Fall back: assume overlap if any files touched + } + + // Get shadow branch tree (the session's content) + refName := plumbing.NewBranchReferenceName(shadowBranchName) + shadowRef, err := repo.Reference(refName, true) + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: shadow branch not found, falling back to filename check", + slog.String("branch", shadowBranchName), + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 // Fall back: assume overlap if any files touched + } + + shadowCommit, err := repo.CommitObject(shadowRef.Hash()) + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow commit, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 + } + + shadowTree, err := shadowCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return len(filesTouched) > 0 + } + + // Get the parent commit tree to determine if files are modified vs newly created. + // For modified files (exist in parent), we count as overlap regardless of content + // because the user is editing the session's work. + // For newly created files (don't exist in parent), we check content to detect + // the "reverted and replaced" scenario where user deleted session's work and + // created something completely different. + var parentTree *object.Tree + if headCommit.NumParents() > 0 { + if parent, err := headCommit.Parent(0); err == nil { + if pTree, err := parent.Tree(); err == nil { + parentTree = pTree + } + } + } + + // Check each file in filesTouched + for _, filePath := range filesTouched { + // Get file from HEAD tree + headFile, err := headTree.File(filePath) + if err != nil { + // File not in HEAD commit - doesn't count as overlap + continue + } + + // Check if this is a modified file (exists in parent) or new file + isModified := false + if parentTree != nil { + if _, err := parentTree.File(filePath); err == nil { + isModified = true + } + } + + // Modified files always count as overlap (user edited session's work) + if isModified { + logging.Debug(logCtx, "filesOverlapWithContent: modified file counts as overlap", + slog.String("file", filePath), + ) + return true + } + + // For new files, check content against shadow branch + shadowFile, err := shadowTree.File(filePath) + if err != nil { + // File not in shadow branch - this shouldn't happen but skip it + logging.Debug(logCtx, "filesOverlapWithContent: file in filesTouched but not in shadow branch", + slog.String("file", filePath), + ) + continue + } + + // Compare by hash (blob hash) - exact content match required for new files + if headFile.Hash == shadowFile.Hash { + logging.Debug(logCtx, "filesOverlapWithContent: new file content match found", + slog.String("file", filePath), + slog.String("hash", headFile.Hash.String()), + ) + return true + } + + logging.Debug(logCtx, "filesOverlapWithContent: new file content mismatch (may be reverted & replaced)", + slog.String("file", filePath), + slog.String("head_hash", headFile.Hash.String()), + slog.String("shadow_hash", shadowFile.Hash.String()), + ) + } + + logging.Debug(logCtx, "filesOverlapWithContent: no overlapping files found", + slog.Int("files_checked", len(filesTouched)), + ) + return false +} + +// stagedFilesOverlapWithContent checks if any staged file overlaps with filesTouched, +// distinguishing between modified files (always overlap) and new files (check content). +// +// For modified files (already exist in HEAD), we count as overlap because the user +// is editing the session's work. For new files (don't exist in HEAD), we require +// content match to detect the "reverted and replaced" scenario. +// +// This is used in PrepareCommitMsg for carry-forward scenarios. +func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree, stagedFiles, filesTouched []string) bool { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Build set of filesTouched for quick lookup + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + touchedSet[f] = true + } + + // Get HEAD tree to determine if files are being modified or newly created + head, err := repo.Head() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD commit, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + headTree, err := headCommit.Tree() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD tree, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + + // Get the git index to access staged file hashes + idx, err := repo.Storer.Index() + if err != nil { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get index, falling back to filename check", + slog.String("error", err.Error()), + ) + return hasOverlappingFiles(stagedFiles, filesTouched) + } + + // Check each staged file + for _, stagedPath := range stagedFiles { + if !touchedSet[stagedPath] { + continue // Not in filesTouched, skip + } + + // Check if this is a modified file (exists in HEAD) or new file + _, headErr := headTree.File(stagedPath) + isModified := headErr == nil + + // Modified files always count as overlap (user edited session's work) + if isModified { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: modified file counts as overlap", + slog.String("file", stagedPath), + ) + return true + } + + // For new files, check content against shadow branch + // Find the index entry to get the staged file's hash + var stagedHash plumbing.Hash + found := false + for _, entry := range idx.Entries { + if entry.Name == stagedPath { + stagedHash = entry.Hash + found = true + break + } + } + if !found { + continue // Not in index (shouldn't happen but be safe) + } + + // Get file from shadow branch tree + shadowFile, err := shadowTree.File(stagedPath) + if err != nil { + // File not in shadow branch - doesn't count as content match + logging.Debug(logCtx, "stagedFilesOverlapWithContent: file not in shadow tree", + slog.String("file", stagedPath), + ) + continue + } + + // Compare hashes - for new files, require exact content match + if stagedHash == shadowFile.Hash { + logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content match found", + slog.String("file", stagedPath), + slog.String("hash", stagedHash.String()), + ) + return true + } + + logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content mismatch (may be reverted & replaced)", + slog.String("file", stagedPath), + slog.String("staged_hash", stagedHash.String()), + slog.String("shadow_hash", shadowFile.Hash.String()), + ) + } + + logging.Debug(logCtx, "stagedFilesOverlapWithContent: no overlapping files found", + slog.Int("staged_files", len(stagedFiles)), + slog.Int("files_touched", len(filesTouched)), + ) + return false +} + +// hasOverlappingFiles checks if any file in stagedFiles appears in filesTouched. +// This is a fallback when content-aware comparison isn't possible. +func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + touchedSet[f] = true + } + + for _, staged := range stagedFiles { + if touchedSet[staged] { + return true + } + } + return false +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 0c880fe23..751be14ce 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1569,8 +1569,19 @@ func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *Sessio //nolint:unparam // error return required by interface but hooks must return nil func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState) error { // Finalize all checkpoints from this turn with the full transcript. - // Best-effort: log warnings but don't fail the hook. - s.finalizeAllTurnCheckpoints(state) + // + // 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(state) + if errCount > 0 { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + logging.Warn(logCtx, "HandleTurnEnd completed with errors (best-effort)", + slog.String("session_id", state.SessionID), + slog.Int("error_count", errCount), + ) + } return nil } @@ -1580,9 +1591,11 @@ func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState) error { // 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. -func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { +// +// Returns the number of errors encountered (best-effort: continues processing on error). +func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) int { if len(state.TurnCheckpointIDs) == 0 { - return // No mid-turn commits to finalize + return 0 // No mid-turn commits to finalize } logCtx := logging.WithComponent(context.Background(), "checkpoint") @@ -1592,13 +1605,15 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.Int("checkpoint_count", len(state.TurnCheckpointIDs)), ) + errCount := 0 + // Read full transcript from live transcript file if state.TranscriptPath == "" { logging.Warn(logCtx, "finalize: no transcript path, skipping", slog.String("session_id", state.SessionID), ) state.TurnCheckpointIDs = nil - return + return 1 // Count as error - all checkpoints will be skipped } fullTranscript, err := os.ReadFile(state.TranscriptPath) @@ -1608,7 +1623,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.String("transcript_path", state.TranscriptPath), ) state.TurnCheckpointIDs = nil - return + return 1 // Count as error - all checkpoints will be skipped } // Extract prompts and context from the full transcript @@ -1625,7 +1640,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.String("error", err.Error()), ) state.TurnCheckpointIDs = nil - return + return 1 // Count as error - all checkpoints will be skipped } for i, p := range prompts { prompts[i] = redact.String(p) @@ -1639,7 +1654,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.String("error", err.Error()), ) state.TurnCheckpointIDs = nil - return + return 1 // Count as error - all checkpoints will be skipped } store := checkpoint.NewGitStore(repo) @@ -1651,6 +1666,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.String("checkpoint_id", cpIDStr), slog.String("error", parseErr.Error()), ) + errCount++ continue } @@ -1667,6 +1683,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { slog.String("checkpoint_id", cpIDStr), slog.String("error", updateErr.Error()), ) + errCount++ continue } @@ -1680,261 +1697,8 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) { fullTranscriptLines := countTranscriptItems(state.AgentType, string(fullTranscript)) state.CheckpointTranscriptStart = fullTranscriptLines state.TurnCheckpointIDs = nil -} - -// filesOverlapWithContent checks if any file in the committed set overlaps with -// filesTouched AND has matching content in the shadow branch. -// -// This prevents linking commits where the user reverted session changes and wrote -// completely different content. Only files whose committed content matches the -// shadow branch content (by hash) are considered true overlaps. -// -// Falls back to filename-only check if shadow branch is not accessible. -func filesOverlapWithContent(repo *git.Repository, shadowBranchName string, headCommit *object.Commit, filesTouched []string) bool { - logCtx := logging.WithComponent(context.Background(), "checkpoint") - - // Build set of filesTouched for quick lookup - touchedSet := make(map[string]bool) - for _, f := range filesTouched { - touchedSet[f] = true - } - - // Get HEAD commit tree (the committed content) - headTree, err := headCommit.Tree() - if err != nil { - logging.Debug(logCtx, "filesOverlapWithContent: failed to get HEAD tree, falling back to filename check", - slog.String("error", err.Error()), - ) - return len(filesTouched) > 0 // Fall back: assume overlap if any files touched - } - - // Get shadow branch tree (the session's content) - refName := plumbing.NewBranchReferenceName(shadowBranchName) - shadowRef, err := repo.Reference(refName, true) - if err != nil { - logging.Debug(logCtx, "filesOverlapWithContent: shadow branch not found, falling back to filename check", - slog.String("branch", shadowBranchName), - slog.String("error", err.Error()), - ) - return len(filesTouched) > 0 // Fall back: assume overlap if any files touched - } - - shadowCommit, err := repo.CommitObject(shadowRef.Hash()) - if err != nil { - logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow commit, falling back to filename check", - slog.String("error", err.Error()), - ) - return len(filesTouched) > 0 - } - - shadowTree, err := shadowCommit.Tree() - if err != nil { - logging.Debug(logCtx, "filesOverlapWithContent: failed to get shadow tree, falling back to filename check", - slog.String("error", err.Error()), - ) - return len(filesTouched) > 0 - } - - // Get the parent commit tree to determine if files are modified vs newly created. - // For modified files (exist in parent), we count as overlap regardless of content - // because the user is editing the session's work. - // For newly created files (don't exist in parent), we check content to detect - // the "reverted and replaced" scenario where user deleted session's work and - // created something completely different. - var parentTree *object.Tree - if headCommit.NumParents() > 0 { - if parent, err := headCommit.Parent(0); err == nil { - if pTree, err := parent.Tree(); err == nil { - parentTree = pTree - } - } - } - - // Check each file in filesTouched - for _, filePath := range filesTouched { - // Get file from HEAD tree - headFile, err := headTree.File(filePath) - if err != nil { - // File not in HEAD commit - doesn't count as overlap - continue - } - - // Check if this is a modified file (exists in parent) or new file - isModified := false - if parentTree != nil { - if _, err := parentTree.File(filePath); err == nil { - isModified = true - } - } - - // Modified files always count as overlap (user edited session's work) - if isModified { - logging.Debug(logCtx, "filesOverlapWithContent: modified file counts as overlap", - slog.String("file", filePath), - ) - return true - } - - // For new files, check content against shadow branch - shadowFile, err := shadowTree.File(filePath) - if err != nil { - // File not in shadow branch - this shouldn't happen but skip it - logging.Debug(logCtx, "filesOverlapWithContent: file in filesTouched but not in shadow branch", - slog.String("file", filePath), - ) - continue - } - - // Compare by hash (blob hash) - exact content match required for new files - if headFile.Hash == shadowFile.Hash { - logging.Debug(logCtx, "filesOverlapWithContent: new file content match found", - slog.String("file", filePath), - slog.String("hash", headFile.Hash.String()), - ) - return true - } - - logging.Debug(logCtx, "filesOverlapWithContent: new file content mismatch (may be reverted & replaced)", - slog.String("file", filePath), - slog.String("head_hash", headFile.Hash.String()), - slog.String("shadow_hash", shadowFile.Hash.String()), - ) - } - - logging.Debug(logCtx, "filesOverlapWithContent: no overlapping files found", - slog.Int("files_checked", len(filesTouched)), - ) - return false -} - -// stagedFilesOverlapWithContent checks if any staged file overlaps with filesTouched, -// distinguishing between modified files (always overlap) and new files (check content). -// -// For modified files (already exist in HEAD), we count as overlap because the user -// is editing the session's work. For new files (don't exist in HEAD), we require -// content match to detect the "reverted and replaced" scenario. -// -// This is used in PrepareCommitMsg for carry-forward scenarios. -func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree, stagedFiles, filesTouched []string) bool { - logCtx := logging.WithComponent(context.Background(), "checkpoint") - - // Build set of filesTouched for quick lookup - touchedSet := make(map[string]bool) - for _, f := range filesTouched { - touchedSet[f] = true - } - - // Get HEAD tree to determine if files are being modified or newly created - head, err := repo.Head() - if err != nil { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD, falling back to filename check", - slog.String("error", err.Error()), - ) - return hasOverlappingFiles(stagedFiles, filesTouched) - } - headCommit, err := repo.CommitObject(head.Hash()) - if err != nil { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD commit, falling back to filename check", - slog.String("error", err.Error()), - ) - return hasOverlappingFiles(stagedFiles, filesTouched) - } - headTree, err := headCommit.Tree() - if err != nil { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get HEAD tree, falling back to filename check", - slog.String("error", err.Error()), - ) - return hasOverlappingFiles(stagedFiles, filesTouched) - } - - // Get the git index to access staged file hashes - idx, err := repo.Storer.Index() - if err != nil { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: failed to get index, falling back to filename check", - slog.String("error", err.Error()), - ) - return hasOverlappingFiles(stagedFiles, filesTouched) - } - - // Check each staged file - for _, stagedPath := range stagedFiles { - if !touchedSet[stagedPath] { - continue // Not in filesTouched, skip - } - // Check if this is a modified file (exists in HEAD) or new file - _, headErr := headTree.File(stagedPath) - isModified := headErr == nil - - // Modified files always count as overlap (user edited session's work) - if isModified { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: modified file counts as overlap", - slog.String("file", stagedPath), - ) - return true - } - - // For new files, check content against shadow branch - // Find the index entry to get the staged file's hash - var stagedHash plumbing.Hash - found := false - for _, entry := range idx.Entries { - if entry.Name == stagedPath { - stagedHash = entry.Hash - found = true - break - } - } - if !found { - continue // Not in index (shouldn't happen but be safe) - } - - // Get file from shadow branch tree - shadowFile, err := shadowTree.File(stagedPath) - if err != nil { - // File not in shadow branch - doesn't count as content match - logging.Debug(logCtx, "stagedFilesOverlapWithContent: file not in shadow tree", - slog.String("file", stagedPath), - ) - continue - } - - // Compare hashes - for new files, require exact content match - if stagedHash == shadowFile.Hash { - logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content match found", - slog.String("file", stagedPath), - slog.String("hash", stagedHash.String()), - ) - return true - } - - logging.Debug(logCtx, "stagedFilesOverlapWithContent: new file content mismatch (may be reverted & replaced)", - slog.String("file", stagedPath), - slog.String("staged_hash", stagedHash.String()), - slog.String("shadow_hash", shadowFile.Hash.String()), - ) - } - - logging.Debug(logCtx, "stagedFilesOverlapWithContent: no overlapping files found", - slog.Int("staged_files", len(stagedFiles)), - slog.Int("files_touched", len(filesTouched)), - ) - return false -} - -// hasOverlappingFiles checks if any file in stagedFiles appears in filesTouched. -func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { - touchedSet := make(map[string]bool) - for _, f := range filesTouched { - touchedSet[f] = true - } - - for _, staged := range stagedFiles { - if touchedSet[staged] { - return true - } - } - return false + return errCount } // filesChangedInCommit returns the set of files changed in a commit by diffing against its parent. From 57e529db762e8b8fdcdfbe2f4888f535468c14ef Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 11:37:16 +0100 Subject: [PATCH 12/22] added an overview diagram with scenarios and mermaid diagramms Entire-Checkpoint: 7e72a8d8fdb8 --- docs/architecture/checkpoint-scenarios.md | 429 ++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/architecture/checkpoint-scenarios.md diff --git a/docs/architecture/checkpoint-scenarios.md b/docs/architecture/checkpoint-scenarios.md new file mode 100644 index 000000000..eec077f72 --- /dev/null +++ b/docs/architecture/checkpoint-scenarios.md @@ -0,0 +1,429 @@ +# Checkpoint Scenarios + +This document describes how the one-to-one checkpoint system handles various user workflows. + +## Overview + +The system uses: +- **Shadow branches** (`entire/-`) - temporary storage for checkpoint data +- **FilesTouched** - accumulates files modified during the session +- **1:1 checkpoints** - each commit gets its own unique checkpoint ID +- **Content-aware overlap** - prevents linking commits where user reverted session changes + +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> IDLE : SessionStart + + IDLE --> ACTIVE : TurnStart (UserPromptSubmit) + ACTIVE --> IDLE : TurnEnd (Stop hook) + ACTIVE --> ACTIVE : GitCommit / Condense + IDLE --> IDLE : GitCommit / Condense + + IDLE --> ENDED : SessionStop + ACTIVE --> ENDED : SessionStop + ENDED --> ACTIVE : TurnStart (session resume) + ENDED --> ENDED : GitCommit / CondenseIfFilesTouched +``` + +--- + +## Scenario 1: Prompt → Changes → Prompt Finishes → User Commits + +The simplest workflow: user runs a prompt, Claude makes changes, prompt finishes, then user manually commits. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: Submit prompt + Note over G: UserPromptSubmit hook + G->>S: InitializeSession (IDLE→ACTIVE) + S->>S: TurnID generated + + C->>C: Makes changes (A, B, C) + C->>G: SaveChanges (Stop hook) + G->>SB: Write checkpoint (A, B, C + transcript) + G->>S: FilesTouched = [A, B, C] + Note over G: TurnEnd: ACTIVE→IDLE + + Note over U: Later... + U->>G: git commit -a + Note over G: PrepareCommitMsg hook + G->>S: Check FilesTouched [A, B, C] + G->>G: staged [A,B,C] ∩ FilesTouched → overlap ✓ + G->>G: Generate checkpoint ID, add trailer + + Note over G: PostCommit hook + G->>G: EventGitCommit (IDLE) + G->>SB: Condense to entire/checkpoints/v1 + G->>SB: Delete shadow branch + G->>S: FilesTouched = nil +``` + +### Key Points +- Shadow branch holds checkpoint data until user commits +- PrepareCommitMsg adds `Entire-Checkpoint` trailer +- PostCommit condenses to permanent storage and cleans up + +--- + +## Scenario 2: Prompt Commits Within Single Turn + +Claude is instructed to commit changes, so the commit happens during the ACTIVE phase. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: "Make changes and commit them" + Note over G: UserPromptSubmit hook + G->>S: InitializeSession (→ACTIVE) + + C->>C: Makes changes (A, B) + C->>G: git add && git commit + + Note over G: PrepareCommitMsg (no TTY = agent commit) + G->>G: Generate checkpoint ID, add trailer directly + + Note over G: PostCommit hook (ACTIVE) + G->>G: EventGitCommit (ACTIVE→ACTIVE) + G->>SB: Condense with provisional transcript + G->>S: TurnCheckpointIDs += [checkpoint-id] + G->>S: FilesTouched = nil + + C->>G: Responds with summary + Note over G: Stop hook + G->>G: HandleTurnEnd (ACTIVE→IDLE) + G->>G: UpdateCommitted: finalize with full transcript + G->>S: TurnCheckpointIDs = nil +``` + +### Key Points +- Agent commits detected by no TTY → fast path adds trailer directly +- **Deferred finalization**: PostCommit saves provisional transcript, HandleTurnEnd updates with full transcript +- TurnCheckpointIDs tracks mid-turn checkpoints for finalization at stop + +--- + +## Scenario 3: Claude Makes Multiple Granular Commits + +Claude is instructed to make granular commits, resulting in multiple commits during one turn. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + + U->>C: "Implement feature with granular commits" + Note over G: UserPromptSubmit → ACTIVE + + C->>C: Creates file A + C->>G: git commit -m "Add A" + Note over G: PrepareCommitMsg: checkpoint-1 + Note over G: PostCommit (ACTIVE) + G->>G: Condense checkpoint-1 (provisional) + G->>S: TurnCheckpointIDs = [checkpoint-1] + + C->>C: Creates file B + C->>G: git commit -m "Add B" + Note over G: PrepareCommitMsg: checkpoint-2 + Note over G: PostCommit (ACTIVE) + G->>G: Condense checkpoint-2 (provisional) + G->>S: TurnCheckpointIDs = [checkpoint-1, checkpoint-2] + + C->>C: Creates file C + C->>G: git commit -m "Add C" + Note over G: PrepareCommitMsg: checkpoint-3 + Note over G: PostCommit (ACTIVE) + G->>G: Condense checkpoint-3 (provisional) + G->>S: TurnCheckpointIDs = [checkpoint-1, checkpoint-2, checkpoint-3] + + C->>G: Summary response + Note over G: Stop hook → HandleTurnEnd + G->>G: Finalize ALL checkpoints with full transcript + Note right of G: checkpoint-1: UpdateCommitted
checkpoint-2: UpdateCommitted
checkpoint-3: UpdateCommitted + G->>S: TurnCheckpointIDs = nil +``` + +### Key Points +- Each commit gets its own unique checkpoint ID (1:1 model) +- All checkpoints are finalized together at turn end +- Each checkpoint has the full session transcript for context + +--- + +## Scenario 4: User Splits Changes Into Multiple Commits + +User decides to create multiple commits from Claude's changes after the prompt finishes. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: Submit prompt + Note over G: UserPromptSubmit → ACTIVE + + C->>C: Makes changes (A, B, C, D) + C->>G: SaveChanges (Stop hook) + G->>SB: Write checkpoint (A, B, C, D) + G->>S: FilesTouched = [A, B, C, D] + Note over G: TurnEnd: ACTIVE→IDLE + + Note over U: User commits A, B only + U->>G: git add A B && git commit + Note over G: PrepareCommitMsg: checkpoint-1 + Note over G: PostCommit (IDLE) + G->>G: committedFiles = {A, B} + G->>G: remaining = [C, D] + G->>SB: Condense checkpoint-1 + G->>SB: Carry-forward C, D to new shadow branch + G->>S: FilesTouched = [C, D] + + Note over U: User commits C, D + U->>G: git add C D && git commit + Note over G: PrepareCommitMsg: checkpoint-2 + Note over G: PostCommit (IDLE) + G->>G: committedFiles = {C, D} + G->>G: remaining = [] + G->>SB: Condense checkpoint-2 + G->>S: FilesTouched = nil +``` + +### Key Points +- **Carry-forward logic**: uncommitted files get a new shadow branch +- Each commit gets its own checkpoint ID (1:1 model) +- Both checkpoints link to the same session transcript + +--- + +## Scenario 5: Partial Commit → Stash → Next Prompt + +User commits some changes, stashes the rest, then runs another prompt. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: Prompt 1 + Note over G: UserPromptSubmit → ACTIVE + + C->>C: Makes changes (A, B, C) + C->>G: Stop hook + G->>SB: Checkpoint (A, B, C) + G->>S: FilesTouched = [A, B, C] + Note over G: ACTIVE→IDLE + + Note over U: User commits A only + U->>G: git add A && git commit + Note over G: PostCommit + G->>SB: Condense checkpoint-1 + G->>SB: Carry-forward B, C + G->>S: FilesTouched = [B, C] + + Note over U: User stashes B, C + U->>U: git stash + Note right of U: B, C removed from working directory
FilesTouched still = [B, C] + + U->>C: Prompt 2 + Note over G: UserPromptSubmit (IDLE→ACTIVE) + G->>S: TurnID = new, TurnCheckpointIDs = nil + Note right of G: FilesTouched NOT cleared
(accumulates across prompts) + + C->>C: Makes changes (D, E) + C->>G: SaveChanges + G->>SB: Add D, E to shadow branch tree + Note right of SB: Tree now has: B, C (old) + D, E (new) + G->>S: FilesTouched = merge([B,C], [D,E]) = [B,C,D,E] + Note over G: ACTIVE→IDLE + + Note over U: User commits D, E + U->>G: git add D E && git commit + Note over G: PrepareCommitMsg + G->>G: staged [D,E] ∩ FilesTouched [B,C,D,E] → D,E match ✓ + G->>G: checkpoint-2 trailer added + + Note over G: PostCommit + G->>G: committedFiles = {D, E} + G->>G: remaining = [B, C] + G->>SB: Condense checkpoint-2 + Note right of G: Checkpoint has FULL session transcript
(both Prompt 1 and Prompt 2) + + G->>G: Carry-forward attempt for B, C + Note right of G: B, C don't exist on disk (stashed)
→ removed from tree + G->>S: FilesTouched = [B, C] +``` + +### Key Points +- **FilesTouched accumulates** across prompts (not cleared at TurnStart) +- **Checkpoints have full session context**: D, E commit links to transcript showing BOTH prompts +- **No wrong attribution**: Looking at checkpoint-2, you can see D, E were created by Prompt 2 + +### Edge Case: Stashed Files Lose Shadow Content + +After user commits D, E, the carry-forward for B, C creates an "empty" checkpoint: +- `buildTreeWithChanges` removes non-existent files (B, C are stashed) from the tree +- A shadow branch commit is created, but its tree is just HEAD (no B, C content) +- `FilesTouched` is set to `[B, C]` - the files are still **tracked by name** + +**If user later unstashes B, C and commits them:** +- PrepareCommitMsg: staged [B, C] overlaps with FilesTouched [B, C] by filename → trailer added ✓ +- PostCommit: checkpoint is created and linked +- But the shadow branch doesn't have the original B, C content from Prompt 1 + +This is acceptable behavior - stashing files mid-session and committing other files first is an explicit user action. The files are still tracked, but the shadow branch content chain is broken. + +--- + +## Scenario 6: Stash → Second Prompt → Unstash → Commit All + +User stashes files, runs another prompt, then unstashes and commits everything together. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: Prompt 1 + Note over G: UserPromptSubmit → ACTIVE + + C->>C: Makes changes (A, B, C) + C->>G: Stop hook + G->>SB: Checkpoint (A, B, C) + G->>S: FilesTouched = [A, B, C] + Note over G: ACTIVE→IDLE + + Note over U: User commits A only + U->>G: git add A && git commit + Note over G: PostCommit + G->>SB: Condense checkpoint-1 + G->>SB: Carry-forward B, C + G->>S: FilesTouched = [B, C] + + Note over U: User stashes B, C + U->>U: git stash + Note right of U: B, C removed from working directory
Shadow branch still has B, C + + U->>C: Prompt 2 + Note over G: UserPromptSubmit (IDLE→ACTIVE) + + C->>C: Makes changes (D, E) + C->>G: Stop hook (SaveChanges) + G->>SB: Add D, E to existing shadow branch + Note right of SB: Tree: B, C (from base) + D, E (new) + G->>S: FilesTouched = merge([B,C], [D,E]) = [B,C,D,E] + Note over G: ACTIVE→IDLE + + Note over U: User unstashes B, C + U->>U: git stash pop + Note right of U: B, C back in working directory + + Note over U: User commits ALL files + U->>G: git add B C D E && git commit + Note over G: PrepareCommitMsg + G->>G: staged [B,C,D,E] ∩ FilesTouched [B,C,D,E] → all match ✓ + G->>G: checkpoint-2 trailer added + + Note over G: PostCommit + G->>G: committedFiles = {B, C, D, E} + G->>G: remaining = [] + G->>SB: Condense checkpoint-2 + Note right of G: Checkpoint includes ALL files (B,C,D,E)
and FULL transcript (both prompts) + G->>S: FilesTouched = nil +``` + +### Key Points +- **Shadow branch accumulates**: D, E added on top of existing B, C from carry-forward +- **All files tracked**: When user commits all together, all four files link to checkpoint +- **Full session context**: Checkpoint transcript shows Prompt 1 created B, C and Prompt 2 created D, E + +### Contrast with Scenario 5 + +| Scenario | User Action | Result | +|----------|-------------|--------| +| **5**: Commit D, E first, then B, C later | Commits D, E while B, C stashed | B, C "fall out" - carry-forward fails, later commit of B, C has no shadow content | +| **6**: Commit all together after unstash | Unstashes B, C, commits B, C, D, E together | All files linked to single checkpoint | + +The key difference is **when the commit happens relative to the unstash**: +- If you commit while files are stashed → those files lose their shadow branch content +- If you unstash first, then commit → all files are preserved together + +--- + +## Content-Aware Overlap Detection + +Prevents linking commits where user reverted session changes and wrote different content. + +```mermaid +flowchart TD + A[PostCommit: Check files overlap] --> B{File in commit AND in FilesTouched?} + B -->|No| Z[No checkpoint trailer] + B -->|Yes| C{File existed in parent commit?} + C -->|Yes: Modified file| D[✓ Counts as overlap
User edited session's work] + C -->|No: New file| E{Content hash matches shadow branch?} + E -->|Yes| F[✓ Counts as overlap
Session's content preserved] + E -->|No| G[✗ No overlap
User reverted & replaced] + + D --> H[Add checkpoint trailer] + F --> H + G --> Z +``` + +### Example: Reverted and Replaced + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + + C->>C: Creates file X with content "hello" + Note over G: Shadow branch: X (hash: abc123) + + U->>U: Reverts: git checkout -- X + U->>U: Writes completely different content + Note right of U: X now has content "world"
(hash: def456) + + U->>G: git add X && git commit + Note over G: PrepareCommitMsg + G->>G: X in FilesTouched? Yes + G->>G: X is new file (not in parent) + G->>G: Compare hashes: abc123 ≠ def456 + G->>G: Content mismatch → NO overlap + Note over G: No Entire-Checkpoint trailer added +``` + +--- + +## Summary Table + +| Scenario | When Checkpoint Created | Checkpoint Contains | Key Mechanism | +|----------|------------------------|---------------------|---------------| +| 1. User commits after prompt | PostCommit (IDLE) | Full transcript | Normal condensation | +| 2. Claude commits in turn | PostCommit (ACTIVE) + HandleTurnEnd | Full transcript (finalized at stop) | Deferred finalization | +| 3. Multiple Claude commits | Each PostCommit (ACTIVE) + HandleTurnEnd | Full transcript per checkpoint | TurnCheckpointIDs tracking | +| 4. User splits commits | Each PostCommit (IDLE) | Full transcript per checkpoint | Carry-forward | +| 5. Partial commit + stash + new prompt + commit new | PostCommit (IDLE) | Full transcript (both prompts) | FilesTouched accumulation, stashed files "fall out" | +| 6. Stash + new prompt + unstash + commit all | PostCommit (IDLE) | All files + full transcript | Shadow branch accumulation | From d88560a53bdaec3bb224e78ee0dab1b82a4cdc99 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 13:20:12 +0100 Subject: [PATCH 13/22] added content aware cary forward not only file name based Entire-Checkpoint: 35111b46167b --- cmd/entire/cli/strategy/content_overlap.go | 122 +++++++++++++++ .../cli/strategy/content_overlap_test.go | 147 ++++++++++++++++++ .../cli/strategy/manual_commit_hooks.go | 6 +- .../cli/strategy/phase_postcommit_test.go | 18 ++- 4 files changed, 287 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index bdbf41697..ba29bca6a 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -279,3 +279,125 @@ func hasOverlappingFiles(stagedFiles, filesTouched []string) bool { } return false } + +// filesWithRemainingAgentChanges returns files from filesTouched that still have +// uncommitted agent changes. This is used for carry-forward after partial commits. +// +// A file has remaining agent changes if: +// - It wasn't committed at all (not in committedFiles), OR +// - It was committed but the committed content doesn't match the shadow branch +// (user committed partial changes, e.g., via git add -p) +// +// Falls back to file-level subtraction if shadow branch is unavailable. +func filesWithRemainingAgentChanges( + repo *git.Repository, + shadowBranchName string, + headCommit *object.Commit, + filesTouched []string, + committedFiles map[string]struct{}, +) []string { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + // Get HEAD commit tree (the committed content) + commitTree, err := headCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesWithRemainingAgentChanges: failed to get commit tree, falling back to file subtraction", + slog.String("error", err.Error()), + ) + return subtractFilesByName(filesTouched, committedFiles) + } + + // Get shadow branch tree (the session's full content) + refName := plumbing.NewBranchReferenceName(shadowBranchName) + shadowRef, err := repo.Reference(refName, true) + if err != nil { + logging.Debug(logCtx, "filesWithRemainingAgentChanges: shadow branch not found, falling back to file subtraction", + slog.String("branch", shadowBranchName), + slog.String("error", err.Error()), + ) + return subtractFilesByName(filesTouched, committedFiles) + } + + shadowCommit, err := repo.CommitObject(shadowRef.Hash()) + if err != nil { + logging.Debug(logCtx, "filesWithRemainingAgentChanges: failed to get shadow commit, falling back to file subtraction", + slog.String("error", err.Error()), + ) + return subtractFilesByName(filesTouched, committedFiles) + } + + shadowTree, err := shadowCommit.Tree() + if err != nil { + logging.Debug(logCtx, "filesWithRemainingAgentChanges: failed to get shadow tree, falling back to file subtraction", + slog.String("error", err.Error()), + ) + return subtractFilesByName(filesTouched, committedFiles) + } + + var remaining []string + + for _, filePath := range filesTouched { + // If file wasn't committed at all, it definitely has remaining changes + if _, wasCommitted := committedFiles[filePath]; !wasCommitted { + remaining = append(remaining, filePath) + logging.Debug(logCtx, "filesWithRemainingAgentChanges: file not committed, keeping", + slog.String("file", filePath), + ) + continue + } + + // File was committed - check if committed content matches shadow branch + shadowFile, err := shadowTree.File(filePath) + if err != nil { + // File not in shadow branch - nothing to carry forward for this file + logging.Debug(logCtx, "filesWithRemainingAgentChanges: file not in shadow branch, skipping", + slog.String("file", filePath), + ) + continue + } + + commitFile, err := commitTree.File(filePath) + if err != nil { + // File not in commit tree (deleted?) - keep it if it's in shadow + remaining = append(remaining, filePath) + logging.Debug(logCtx, "filesWithRemainingAgentChanges: file not in commit tree but in shadow, keeping", + slog.String("file", filePath), + ) + continue + } + + // Compare hashes - if different, there are still uncommitted agent changes + if commitFile.Hash != shadowFile.Hash { + remaining = append(remaining, filePath) + logging.Debug(logCtx, "filesWithRemainingAgentChanges: content mismatch, keeping for carry-forward", + slog.String("file", filePath), + slog.String("commit_hash", commitFile.Hash.String()[:7]), + slog.String("shadow_hash", shadowFile.Hash.String()[:7]), + ) + } else { + logging.Debug(logCtx, "filesWithRemainingAgentChanges: content fully committed", + slog.String("file", filePath), + ) + } + } + + logging.Debug(logCtx, "filesWithRemainingAgentChanges: result", + slog.Int("files_touched", len(filesTouched)), + slog.Int("committed_files", len(committedFiles)), + slog.Int("remaining_files", len(remaining)), + ) + + return remaining +} + +// subtractFilesByName returns files from filesTouched that are NOT in committedFiles. +// This is a fallback when content-aware comparison isn't possible. +func subtractFilesByName(filesTouched []string, committedFiles map[string]struct{}) []string { + var remaining []string + for _, f := range filesTouched { + if _, committed := committedFiles[f]; !committed { + remaining = append(remaining, f) + } + } + return remaining +} diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go index ac8633e06..14794793a 100644 --- a/cmd/entire/cli/strategy/content_overlap_test.go +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -206,6 +206,153 @@ func TestFilesOverlapWithContent_NoShadowBranch(t *testing.T) { assert.True(t, result, "Missing shadow branch should fall back to assuming overlap") } +// TestFilesWithRemainingAgentChanges_FileNotCommitted tests that files not in the commit +// are kept in the remaining list. +func TestFilesWithRemainingAgentChanges_FileNotCommitted(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create shadow branch with two files + createShadowBranchWithContent(t, repo, "abc1234", "e3b0c4", map[string][]byte{ + "fileA.txt": []byte("content A"), + "fileB.txt": []byte("content B"), + }) + + // Only commit fileA + fileA := filepath.Join(dir, "fileA.txt") + require.NoError(t, os.WriteFile(fileA, []byte("content A"), 0o644)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("fileA.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Add file A only", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + shadowBranch := checkpoint.ShadowBranchNameForCommit("abc1234", "e3b0c4") + committedFiles := map[string]struct{}{"fileA.txt": {}} + + // fileB was not committed - should be in remaining + remaining := filesWithRemainingAgentChanges(repo, shadowBranch, commit, []string{"fileA.txt", "fileB.txt"}, committedFiles) + assert.Equal(t, []string{"fileB.txt"}, remaining, "Uncommitted file should be in remaining") +} + +// TestFilesWithRemainingAgentChanges_FullyCommitted tests that files committed with +// matching content are NOT in the remaining list. +func TestFilesWithRemainingAgentChanges_FullyCommitted(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + content := []byte("exact same content") + + // Create shadow branch with file + createShadowBranchWithContent(t, repo, "def5678", "e3b0c4", map[string][]byte{ + "test.txt": content, + }) + + // Commit the file with SAME content + testFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(testFile, content, 0o644)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("test.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Add file with same content", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + shadowBranch := checkpoint.ShadowBranchNameForCommit("def5678", "e3b0c4") + committedFiles := map[string]struct{}{"test.txt": {}} + + // File was fully committed - should NOT be in remaining + remaining := filesWithRemainingAgentChanges(repo, shadowBranch, commit, []string{"test.txt"}, committedFiles) + assert.Empty(t, remaining, "Fully committed file should not be in remaining") +} + +// TestFilesWithRemainingAgentChanges_PartialCommit tests that files committed with +// different content (partial commit via git add -p) ARE in the remaining list. +func TestFilesWithRemainingAgentChanges_PartialCommit(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Shadow branch has the full agent content + fullContent := []byte("line 1\nline 2\nline 3\nline 4\n") + createShadowBranchWithContent(t, repo, "ghi9012", "e3b0c4", map[string][]byte{ + "test.txt": fullContent, + }) + + // User commits only partial content (simulating git add -p) + partialContent := []byte("line 1\nline 2\n") + testFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(testFile, partialContent, 0o644)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("test.txt") + require.NoError(t, err) + headCommit, err := wt.Commit("Partial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + shadowBranch := checkpoint.ShadowBranchNameForCommit("ghi9012", "e3b0c4") + committedFiles := map[string]struct{}{"test.txt": {}} + + // Content doesn't match - file should be in remaining (has more agent changes) + remaining := filesWithRemainingAgentChanges(repo, shadowBranch, commit, []string{"test.txt"}, committedFiles) + assert.Equal(t, []string{"test.txt"}, remaining, "Partially committed file should be in remaining") +} + +// TestFilesWithRemainingAgentChanges_NoShadowBranch tests fallback to file-level subtraction. +func TestFilesWithRemainingAgentChanges_NoShadowBranch(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create a commit without any shadow branch + testFile := filepath.Join(dir, "test.txt") + 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) + headCommit, err := wt.Commit("Test commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + // Non-existent shadow branch should fall back to file-level subtraction + committedFiles := map[string]struct{}{"test.txt": {}} + remaining := filesWithRemainingAgentChanges(repo, "entire/nonexistent-e3b0c4", commit, []string{"test.txt", "other.txt"}, committedFiles) + + // With file-level subtraction: test.txt is in committedFiles, other.txt is not + assert.Equal(t, []string{"other.txt"}, remaining, "Fallback should use file-level subtraction") +} + // createShadowBranchWithContent creates a shadow branch with the given file contents. // This helper directly uses go-git APIs to avoid paths.RepoRoot() dependency. // diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 751be14ce..4358b696a 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -647,9 +647,11 @@ func (s *ManualCommitStrategy) PostCommit() error { // 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. if condensed { - remainingFiles := subtractFiles(filesTouchedBefore, committedFileSet) - logging.Debug(logCtx, "post-commit: carry-forward decision", + remainingFiles := filesWithRemainingAgentChanges(repo, shadowBranchName, commit, filesTouchedBefore, committedFileSet) + 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)), diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 607873c55..1afe204e9 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -1170,9 +1170,16 @@ func TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs(t *testing.T) { // 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 := ".entire/metadata/" + sessionID metadataDirAbs := filepath.Join(dir, metadataDir) @@ -1186,9 +1193,10 @@ func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Re []byte(transcript), 0o644)) // SaveChanges creates the shadow branch and checkpoint + // Include test.txt as a modified file so it's saved to the shadow branch err := s.SaveChanges(SaveContext{ SessionID: sessionID, - ModifiedFiles: []string{}, + ModifiedFiles: []string{"test.txt"}, NewFiles: []string{}, DeletedFiles: []string{}, MetadataDir: metadataDir, @@ -1209,15 +1217,17 @@ func commitWithCheckpointTrailer(t *testing.T, repo *git.Repository, dir, checkp } // commitFilesWithTrailer stages the given files and commits with a checkpoint trailer. -// Files must already exist on disk. A test.txt is also touched to ensure there's always something to commit. +// 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) - // Always touch test.txt so the commit is never empty + // Modify test.txt with agent-like content that matches what setupSessionWithCheckpointAndFile saves testFile := filepath.Join(dir, "test.txt") - content := "updated at " + time.Now().String() + content := "agent modified content" require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644)) wt, err := repo.Worktree() From 9da669d8cf35caede3d4b5051e662fe77d96f3ae Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 13:20:54 +0100 Subject: [PATCH 14/22] more flows / updates to latest changes Entire-Checkpoint: bd1e99effa13 --- docs/architecture/checkpoint-scenarios.md | 184 +++++++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/docs/architecture/checkpoint-scenarios.md b/docs/architecture/checkpoint-scenarios.md index eec077f72..570ce03e9 100644 --- a/docs/architecture/checkpoint-scenarios.md +++ b/docs/architecture/checkpoint-scenarios.md @@ -210,6 +210,16 @@ sequenceDiagram - Each commit gets its own checkpoint ID (1:1 model) - Both checkpoints link to the same session transcript +### Content-Aware Carry-Forward + +The carry-forward logic uses **content-aware comparison** to determine which files have remaining uncommitted changes: + +1. **File not in commit** → definitely has remaining changes +2. **File in commit, hash matches shadow branch** → fully committed, no carry-forward +3. **File in commit, hash differs from shadow branch** → partial commit (e.g., `git add -p`), carry forward + +This enables splitting changes within a single file across multiple commits (see Scenario 7). + --- ## Scenario 5: Partial Commit → Stash → Next Prompt @@ -372,6 +382,80 @@ The key difference is **when the commit happens relative to the unstash**: --- +## Scenario 7: Partial Staging with `git add -p` + +User uses interactive staging to commit only some hunks of a file, leaving other agent changes uncommitted. + +```mermaid +sequenceDiagram + participant U as User + participant C as Claude + participant G as Git Hooks + participant S as Session State + participant SB as Shadow Branch + + U->>C: Submit prompt + Note over G: UserPromptSubmit → ACTIVE + + C->>C: Makes multiple changes to file A + Note right of C: A now has lines 1-100
(was empty before) + C->>G: Stop hook + G->>SB: Checkpoint (A with lines 1-100) + G->>S: FilesTouched = [A] + Note over G: ACTIVE→IDLE + + Note over U: User stages partial content + U->>G: git add -p A + Note right of U: Stages only lines 1-50
Worktree still has 1-100 + + U->>G: git commit + Note over G: PrepareCommitMsg: checkpoint-1 + + Note over G: PostCommit + G->>G: committedFiles = {A} + G->>G: Content check: committed A (lines 1-50) + G->>G: Shadow A hash ≠ committed A hash + G->>G: remaining = [A] (has uncommitted changes) + G->>SB: Condense checkpoint-1 + G->>SB: Carry-forward A to new shadow branch + Note right of SB: New shadow has A with
current worktree (lines 1-100) + G->>S: FilesTouched = [A] + + Note over U: User commits remaining + U->>G: git add A && git commit + Note over G: PrepareCommitMsg: checkpoint-2 + + Note over G: PostCommit + G->>G: Content check: committed A == shadow A + G->>G: remaining = [] + G->>SB: Condense checkpoint-2 + G->>S: FilesTouched = nil +``` + +### Key Points +- **Content-aware carry-forward**: Compares git blob hashes, not just filenames +- Partial staging (`git add -p`) within a single file is detected +- Each commit gets proper attribution, even when splitting one file's changes + +### How Content Comparison Works + +```mermaid +flowchart TD + A[PostCommit: Carry-forward check] --> B{File in committedFiles?} + B -->|No| C[✓ Add to remaining
File not committed at all] + B -->|Yes| D[Get shadow branch file hash] + D --> E{Shadow file exists?} + E -->|No| F[Skip file
Nothing to compare against] + E -->|Yes| G{Committed hash == shadow hash?} + G -->|Yes| H[Skip file
Fully committed] + G -->|No| I[✓ Add to remaining
Partial commit detected] + + C --> J[Carry forward remaining files] + I --> J +``` + +--- + ## Content-Aware Overlap Detection Prevents linking commits where user reverted session changes and wrote different content. @@ -424,6 +508,104 @@ sequenceDiagram | 1. User commits after prompt | PostCommit (IDLE) | Full transcript | Normal condensation | | 2. Claude commits in turn | PostCommit (ACTIVE) + HandleTurnEnd | Full transcript (finalized at stop) | Deferred finalization | | 3. Multiple Claude commits | Each PostCommit (ACTIVE) + HandleTurnEnd | Full transcript per checkpoint | TurnCheckpointIDs tracking | -| 4. User splits commits | Each PostCommit (IDLE) | Full transcript per checkpoint | Carry-forward | +| 4. User splits commits | Each PostCommit (IDLE) | Full transcript per checkpoint | Content-aware carry-forward | | 5. Partial commit + stash + new prompt + commit new | PostCommit (IDLE) | Full transcript (both prompts) | FilesTouched accumulation, stashed files "fall out" | | 6. Stash + new prompt + unstash + commit all | PostCommit (IDLE) | All files + full transcript | Shadow branch accumulation | +| 7. Partial staging with `git add -p` | Each PostCommit (IDLE) | Full transcript per checkpoint | Content-aware carry-forward (hash comparison) | + +--- + +## Known Caveats + +### 1. Redundant Transcript Data Across Commits + +Each checkpoint stores the **full session transcript** up to that point. If a session results in multiple commits (Scenarios 3, 4, 5, 6), each checkpoint contains overlapping transcript data. + +**Example**: Session with 3 commits +- Checkpoint 1: transcript lines 1-100 +- Checkpoint 2: transcript lines 1-200 (includes 1-100 again) +- Checkpoint 3: transcript lines 1-300 (includes 1-200 again) + +**Trade-off**: This simplifies checkpoint retrieval (each is self-contained) at the cost of storage efficiency. + +### 2. Token Usage Sums Are Misleading + +Each checkpoint's `metadata.json` contains cumulative token usage for the entire session up to that point. Summing token counts across multiple checkpoints from the same session **double-counts tokens**. + +**Example**: +- Checkpoint 1: 10,000 tokens (session total so far) +- Checkpoint 2: 25,000 tokens (session total so far) +- Naive sum: 35,000 tokens ❌ +- Actual usage: 25,000 tokens ✓ + +**Correct approach**: Use the token count from the **last checkpoint** of a session, or track incremental deltas separately. + +### 3. Stashed Files Lose Shadow Content + +As described in Scenario 5, if files are stashed and other files are committed first, the stashed files lose their content in the shadow branch. They remain tracked by filename in `FilesTouched`, but subsequent checkpoints won't have the original file content preserved. + +### 4. No Per-File Prompt Attribution + +Checkpoints don't explicitly tag which prompt created which file. To determine this, you must parse the transcript and correlate `tool_use` entries with preceding `user` messages. The `files_touched` list in metadata is cumulative across all prompts. + +### 5. Carry-Forward Checkpoints Include Full Transcript + +When files are carried forward (Scenario 4), `CheckpointTranscriptStart` is reset to 0. This means each carry-forward checkpoint includes the **entire transcript**, not just new content since the last checkpoint. + +**Impact**: For long sessions with many partial commits, checkpoint storage grows linearly with session length × number of commits. + +### 6. Crash Before HandleTurnEnd Leaves Provisional Transcripts + +In Scenarios 2 and 3 (Claude commits during turn), checkpoints are saved with "provisional" transcripts during PostCommit. The full transcript is written at HandleTurnEnd (Stop hook). + +If the session crashes or is killed before the Stop hook fires: +- Checkpoints exist with partial transcripts +- `TurnCheckpointIDs` in session state tracks which need finalization +- Next session start does **not** automatically finalize orphaned checkpoints + +### 7. Two Different Content-Aware Checks + +The system uses two separate content-aware checks with different purposes: + +**A. Overlap Detection** (`filesOverlapWithContent`) - Determines if commit should be linked to session: +- Only applies to **newly created files** +- Modified files (existed in parent) **always count as overlap** +- Used in PrepareCommitMsg/PostCommit for non-ACTIVE sessions +- **Purpose**: Prevent linking commits where user reverted session content + +**B. Carry-Forward Detection** (`filesWithRemainingAgentChanges`) - Determines which files to carry forward: +- Applies to **all committed files** +- Compares committed content hash vs shadow branch hash +- Hash mismatch = partial commit, file carried forward +- **Purpose**: Enable splitting changes within a file across commits (Scenario 7) + +### 8. Carry-Forward Content Superseded by New Prompts + +When files are carried forward and then a new prompt modifies the same file: +- The shadow branch gets the **new** content (from the new prompt's SaveChanges) +- The carried-forward content is overwritten +- Subsequent commits compare against the **new prompt's content**, not the original carried-forward content + +**Example**: +1. Prompt 1: Agent writes 100 lines to file A +2. User commits 50 lines via `git add -p` +3. Carry-forward: A (with 100 lines) goes to new shadow branch +4. Prompt 2: Agent adds 50 more lines to A (now 150 lines total in worktree) +5. SaveChanges: Shadow branch now has A with 150 lines +6. User commits: Comparison is against 150 lines, not original 100 lines + +This is correct behavior - the shadow branch reflects the **current combined state** of the session's work. + +### 9. Automatic Cleanup During Normal Operations + +Most orphaned data is cleaned up automatically: + +- **Shadow branches**: Deleted after condensation if no other sessions reference them +- **Session states**: Cleaned up during session listing when shadow branch no longer exists (and session is not ACTIVE, has no `LastCheckpointID`) + +For anything that slips through, run `entire clean` manually: + +```bash +entire clean # Preview orphaned items +entire clean --force # Delete orphaned items +``` From 4c20622e519916f6294688b8cc35b445fb1465ee Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 14:22:30 +0100 Subject: [PATCH 15/22] review feedback Entire-Checkpoint: 81e66f80705a --- cmd/entire/cli/checkpoint/committed.go | 15 +- .../deferred_finalization_test.go | 71 ++------- cmd/entire/cli/integration_test/hooks.go | 22 +++ .../phase_transitions_test.go | 31 +--- cmd/entire/cli/strategy/content_overlap.go | 17 +- .../cli/strategy/content_overlap_test.go | 146 ++++++++++++++++++ 6 files changed, 200 insertions(+), 102 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 0bd5b8deb..82836804b 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1074,15 +1074,20 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) // Replace transcript (full replace, not append) + // Apply redaction as safety net (caller should redact, but we ensure it here) if len(opts.Transcript) > 0 { - if err := s.replaceTranscript(opts.Transcript, opts.Agent, sessionPath, entries); err != nil { + transcript, err := redact.JSONLBytes(opts.Transcript) + if err != nil { + return fmt.Errorf("failed to redact transcript secrets: %w", err) + } + if err := s.replaceTranscript(transcript, opts.Agent, sessionPath, entries); err != nil { return fmt.Errorf("failed to replace transcript: %w", err) } } - // Replace prompts + // Replace prompts (apply redaction as safety net) if len(opts.Prompts) > 0 { - promptContent := strings.Join(opts.Prompts, "\n\n---\n\n") + promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n")) blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent)) if err != nil { return fmt.Errorf("failed to create prompt blob: %w", err) @@ -1094,9 +1099,9 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti } } - // Replace context + // Replace context (apply redaction as safety net) if len(opts.Context) > 0 { - contextBlob, err := CreateBlobFromContent(s.repo, opts.Context) + contextBlob, err := CreateBlobFromContent(s.repo, redact.Bytes(opts.Context)) if err != nil { return fmt.Errorf("failed to create context blob: %w", err) } diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index 509b3b3de..5e71962ce 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -3,8 +3,6 @@ package integration import ( - "bytes" - "encoding/json" "os" "os/exec" "path/filepath" @@ -39,27 +37,10 @@ func TestShadow_DeferredTranscriptFinalization(t *testing.T) { sess := env.NewSession() - // Helper to submit with transcript path (needed for mid-session commit detection) - submitWithTranscriptPath := func(sessionID, transcriptPath string) { - t.Helper() - input := map[string]string{ - "session_id": sessionID, - "transcript_path": transcriptPath, - } - inputJSON, _ := json.Marshal(input) - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = env.RepoDir - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) - } - } - // Start session (ACTIVE) - submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) + } state, err := env.GetSessionState(sess.ID) if err != nil { @@ -242,27 +223,10 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { sess := env.NewSession() - // Helper to submit with transcript path - submitWithTranscriptPath := func(sessionID, transcriptPath string) { - t.Helper() - input := map[string]string{ - "session_id": sessionID, - "transcript_path": transcriptPath, - } - inputJSON, _ := json.Marshal(input) - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = env.RepoDir - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) - } - } - // Start session (ACTIVE) - submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) + } // Create multiple files env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n") @@ -433,27 +397,10 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { sess := env.NewSession() - // Helper to submit with transcript path - submitWithTranscriptPath := func(sessionID, transcriptPath string) { - t.Helper() - input := map[string]string{ - "session_id": sessionID, - "transcript_path": transcriptPath, - } - inputJSON, _ := json.Marshal(input) - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = env.RepoDir - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) - } - } - // Start session (ACTIVE) - submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) + } // Create multiple files env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n") diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index b748203b0..2b5da387b 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -56,6 +56,20 @@ func (r *HookRunner) SimulateUserPromptSubmit(sessionID string) error { return r.runHookWithInput("user-prompt-submit", input) } +// SimulateUserPromptSubmitWithTranscriptPath simulates the UserPromptSubmit hook +// with an explicit transcript path. This is needed for mid-session commit detection +// which reads the live transcript to detect ongoing sessions. +func (r *HookRunner) SimulateUserPromptSubmitWithTranscriptPath(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runHookWithInput("user-prompt-submit", input) +} + // SimulateUserPromptSubmitWithResponse simulates the UserPromptSubmit hook // and returns the parsed hook response (for testing blocking behavior). func (r *HookRunner) SimulateUserPromptSubmitWithResponse(sessionID string) (*HookResponse, error) { @@ -253,6 +267,14 @@ func (env *TestEnv) SimulateUserPromptSubmit(sessionID string) error { return runner.SimulateUserPromptSubmit(sessionID) } +// SimulateUserPromptSubmitWithTranscriptPath is a convenience method on TestEnv. +// This is needed for mid-session commit detection which reads the live transcript. +func (env *TestEnv) SimulateUserPromptSubmitWithTranscriptPath(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewHookRunner(env.RepoDir, env.ClaudeProjectDir, env.T) + return runner.SimulateUserPromptSubmitWithTranscriptPath(sessionID, transcriptPath) +} + // SimulateUserPromptSubmitWithResponse is a convenience method on TestEnv. func (env *TestEnv) SimulateUserPromptSubmitWithResponse(sessionID string) (*HookResponse, error) { env.T.Helper() diff --git a/cmd/entire/cli/integration_test/phase_transitions_test.go b/cmd/entire/cli/integration_test/phase_transitions_test.go index d117190db..6f3acbbd4 100644 --- a/cmd/entire/cli/integration_test/phase_transitions_test.go +++ b/cmd/entire/cli/integration_test/phase_transitions_test.go @@ -3,10 +3,6 @@ package integration import ( - "bytes" - "encoding/json" - "os" - "os/exec" "testing" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -36,28 +32,11 @@ func TestShadow_CommitBeforeStop(t *testing.T) { sess := env.NewSession() - // Pass transcript path in the hook input so it's stored in session state - // (needed for mid-session commit detection via live transcript) - submitWithTranscriptPath := func(sessionID, transcriptPath string) { - t.Helper() - input := map[string]string{ - "session_id": sessionID, - "transcript_path": transcriptPath, - } - inputJSON, _ := json.Marshal(input) - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = env.RepoDir - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("user-prompt-submit failed: %v\nOutput: %s", err, output) - } + // Start session with transcript path (needed for mid-session commit detection via live transcript) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) } - submitWithTranscriptPath(sess.ID, sess.TranscriptPath) - // Verify session is ACTIVE state, err := env.GetSessionState(sess.ID) if err != nil { @@ -106,7 +85,9 @@ func TestShadow_CommitBeforeStop(t *testing.T) { // ======================================== t.Log("Phase 2: Start new turn, create more work") - submitWithTranscriptPath(sess.ID, sess.TranscriptPath) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) + } state, err = env.GetSessionState(sess.ID) if err != nil { diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index ba29bca6a..9f6a434a5 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -198,6 +198,12 @@ func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree return hasOverlappingFiles(stagedFiles, filesTouched) } + // Build a map of index entries for O(1) lookup (avoid O(n*m) nested loop) + indexEntries := make(map[string]plumbing.Hash, len(idx.Entries)) + for _, entry := range idx.Entries { + indexEntries[entry.Name] = entry.Hash + } + // Check each staged file for _, stagedPath := range stagedFiles { if !touchedSet[stagedPath] { @@ -217,16 +223,7 @@ func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree } // For new files, check content against shadow branch - // Find the index entry to get the staged file's hash - var stagedHash plumbing.Hash - found := false - for _, entry := range idx.Entries { - if entry.Name == stagedPath { - stagedHash = entry.Hash - found = true - break - } - } + stagedHash, found := indexEntries[stagedPath] if !found { continue // Not in index (shouldn't happen but be safe) } diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go index 14794793a..b85e2dc30 100644 --- a/cmd/entire/cli/strategy/content_overlap_test.go +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -353,6 +353,152 @@ func TestFilesWithRemainingAgentChanges_NoShadowBranch(t *testing.T) { assert.Equal(t, []string{"other.txt"}, remaining, "Fallback should use file-level subtraction") } +// 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(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(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(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(repo, shadowTree, []string{"other.txt"}, []string{"session.txt"}) + assert.False(t, result, "Non-overlapping files should return false") +} + // createShadowBranchWithContent creates a shadow branch with the given file contents. // This helper directly uses go-git APIs to avoid paths.RepoRoot() dependency. // From c108ffd7281b5d00226bb7bb40ae348f61cac3d3 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 22:28:04 +0100 Subject: [PATCH 16/22] integration tests matching all checkpoint scenarios Entire-Checkpoint: 4080b6c2257f --- .../scenario_checkpoint_workflows_test.go | 572 ++++++++++++++++++ cmd/entire/cli/e2e_test/testenv.go | 111 ++++ 2 files changed, 683 insertions(+) create mode 100644 cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go new file mode 100644 index 000000000..1c1f7d3ad --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go @@ -0,0 +1,572 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests cover the scenarios documented in docs/architecture/checkpoint-scenarios.md + +// TestE2E_Scenario3_MultipleGranularCommits tests Claude making multiple granular commits +// during a single turn. +// +// Scenario 3: Claude Makes Multiple Granular Commits +// - Claude is instructed to make granular commits +// - Multiple commits happen during one turn +// - Each commit gets its own unique checkpoint ID +// - All checkpoints are finalized together at turn end +func TestE2E_Scenario3_MultipleGranularCommits(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Count commits before + commitsBefore := env.GetCommitCount() + t.Logf("Commits before: %d", commitsBefore) + + // Agent creates multiple files and commits them separately + granularCommitPrompt := `Please do the following tasks, committing after each one: + +1. Create a file called file1.go with this content: + package main + func One() int { return 1 } + Then run: git add file1.go && git commit -m "Add file1" + +2. Create a file called file2.go with this content: + package main + func Two() int { return 2 } + Then run: git add file2.go && git commit -m "Add file2" + +3. Create a file called file3.go with this content: + package main + func Three() int { return 3 } + Then run: git add file3.go && git commit -m "Add file3" + +Do each task in order, making the commit after each file creation.` + + result, err := env.RunAgentWithTools(granularCommitPrompt, []string{"Write", "Bash"}) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + t.Logf("Agent output: %s", result.Stdout) + + // Verify all files were created + assert.True(t, env.FileExists("file1.go"), "file1.go should exist") + assert.True(t, env.FileExists("file2.go"), "file2.go should exist") + assert.True(t, env.FileExists("file3.go"), "file3.go should exist") + + // Verify multiple commits were made + commitsAfter := env.GetCommitCount() + t.Logf("Commits after: %d", commitsAfter) + assert.GreaterOrEqual(t, commitsAfter-commitsBefore, 3, "Should have at least 3 new commits") + + // Get all checkpoint IDs from history + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + t.Logf("Found %d checkpoint IDs in commit history", len(checkpointIDs)) + for i, id := range checkpointIDs { + t.Logf(" Checkpoint %d: %s", i, id) + } + + // Each commit should have its own unique checkpoint ID (1:1 model) + if len(checkpointIDs) >= 3 { + // Verify checkpoint IDs are unique + idSet := make(map[string]bool) + for _, id := range checkpointIDs { + require.Falsef(t, idSet[id], "Checkpoint IDs should be unique: %s is duplicated", id) + idSet[id] = true + } + } + + // Verify metadata branch exists + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_Scenario4_UserSplitsCommits tests user splitting agent changes into multiple commits. +// +// Scenario 4: User Splits Changes Into Multiple Commits +// - Agent makes changes to multiple files +// - User commits only some files first +// - Uncommitted files are carried forward +// - User commits remaining files later +// - Each commit gets its own checkpoint ID +func TestE2E_Scenario4_UserSplitsCommits(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Agent creates multiple files in one prompt + multiFilePrompt := `Create these files: +1. fileA.go with content: package main; func A() string { return "A" } +2. fileB.go with content: package main; func B() string { return "B" } +3. fileC.go with content: package main; func C() string { return "C" } +4. fileD.go with content: package main; func D() string { return "D" } + +Create all four files, no other files or actions.` + + result, err := env.RunAgent(multiFilePrompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify all files were created + assert.True(t, env.FileExists("fileA.go"), "fileA.go should exist") + assert.True(t, env.FileExists("fileB.go"), "fileB.go should exist") + assert.True(t, env.FileExists("fileC.go"), "fileC.go should exist") + assert.True(t, env.FileExists("fileD.go"), "fileD.go should exist") + + // Check rewind points before commit + pointsBefore := env.GetRewindPoints() + t.Logf("Rewind points before commit: %d", len(pointsBefore)) + + // User commits only A, B first + t.Log("Committing fileA.go and fileB.go only") + env.GitCommitWithShadowHooks("Add files A and B", "fileA.go", "fileB.go") + + // Verify first checkpoint was created + checkpointIDsAfterFirstCommit := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDsAfterFirstCommit), 1, "Should have first checkpoint") + // Note: GetAllCheckpointIDsFromHistory returns IDs in reverse chronological order (newest first) + checkpointAB := checkpointIDsAfterFirstCommit[0] + t.Logf("Checkpoint for A,B commit: %s", checkpointAB) + + // Check rewind points - should still have points for uncommitted files + pointsAfterFirst := env.GetRewindPoints() + t.Logf("Rewind points after first commit: %d", len(pointsAfterFirst)) + + // User commits C, D later + t.Log("Committing fileC.go and fileD.go") + env.GitCommitWithShadowHooks("Add files C and D", "fileC.go", "fileD.go") + + // Verify second checkpoint was created with different ID + checkpointIDsAfterSecondCommit := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDsAfterSecondCommit), 2, "Should have two checkpoints") + checkpointCD := checkpointIDsAfterSecondCommit[0] // Most recent (C,D commit) is first + t.Logf("Checkpoint for C,D commit: %s", checkpointCD) + + // Each commit should have its own unique checkpoint ID (1:1 model) + assert.NotEqual(t, checkpointAB, checkpointCD, + "Each commit should have its own unique checkpoint ID") + + // Verify metadata branch exists + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_Scenario5_PartialCommitStashNextPrompt tests partial commit, stash, then new prompt. +// +// Scenario 5: Partial Commit → Stash → Next Prompt +// - Agent makes changes to A, B, C +// - User commits A only +// - User stashes B, C +// - User runs another prompt (creates D, E) +// - User commits D, E +// - FilesTouched accumulates across prompts +func TestE2E_Scenario5_PartialCommitStashNextPrompt(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Prompt 1: Agent creates files A, B, C + t.Log("Prompt 1: Creating files A, B, C") + prompt1 := `Create these files: +1. stash_a.go with content: package main; func StashA() {} +2. stash_b.go with content: package main; func StashB() {} +3. stash_c.go with content: package main; func StashC() {} +Create all three files, nothing else.` + + result, err := env.RunAgent(prompt1) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("stash_a.go")) + require.True(t, env.FileExists("stash_b.go")) + require.True(t, env.FileExists("stash_c.go")) + + // User commits A only + t.Log("Committing stash_a.go only") + env.GitCommitWithShadowHooks("Add stash_a", "stash_a.go") + + // User stashes B, C + t.Log("Stashing remaining files") + env.GitStash() + + // Verify B, C are no longer in working directory + assert.False(t, env.FileExists("stash_b.go"), "stash_b.go should be stashed") + assert.False(t, env.FileExists("stash_c.go"), "stash_c.go should be stashed") + + // Prompt 2: Agent creates files D, E + t.Log("Prompt 2: Creating files D, E") + prompt2 := `Create these files: +1. stash_d.go with content: package main; func StashD() {} +2. stash_e.go with content: package main; func StashE() {} +Create both files, nothing else.` + + result, err = env.RunAgent(prompt2) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("stash_d.go")) + require.True(t, env.FileExists("stash_e.go")) + + // User commits D, E + t.Log("Committing stash_d.go and stash_e.go") + env.GitCommitWithShadowHooks("Add stash_d and stash_e", "stash_d.go", "stash_e.go") + + // Verify checkpoint was created for D, E + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDs), 2, "Should have checkpoints for both commits") + t.Logf("Checkpoint IDs: %v", checkpointIDs) + + // Verify metadata branch exists + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_Scenario6_StashSecondPromptUnstashCommitAll tests stash, new prompt, unstash, commit all. +// +// Scenario 6: Stash → Second Prompt → Unstash → Commit All +// - Agent makes changes to A, B, C +// - User commits A only +// - User stashes B, C +// - User runs another prompt (creates D, E) +// - User unstashes B, C +// - User commits ALL (B, C, D, E) together +// - All files link to single checkpoint +func TestE2E_Scenario6_StashSecondPromptUnstashCommitAll(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Prompt 1: Agent creates files A, B, C + t.Log("Prompt 1: Creating files A, B, C") + prompt1 := `Create these files: +1. combo_a.go with content: package main; func ComboA() {} +2. combo_b.go with content: package main; func ComboB() {} +3. combo_c.go with content: package main; func ComboC() {} +Create all three files, nothing else.` + + result, err := env.RunAgent(prompt1) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("combo_a.go")) + require.True(t, env.FileExists("combo_b.go")) + require.True(t, env.FileExists("combo_c.go")) + + // User commits A only + t.Log("Committing combo_a.go only") + env.GitCommitWithShadowHooks("Add combo_a", "combo_a.go") + + // User stashes B, C + t.Log("Stashing remaining files B, C") + env.GitStash() + + // Verify B, C are no longer in working directory + assert.False(t, env.FileExists("combo_b.go"), "combo_b.go should be stashed") + assert.False(t, env.FileExists("combo_c.go"), "combo_c.go should be stashed") + + // Prompt 2: Agent creates files D, E + t.Log("Prompt 2: Creating files D, E") + prompt2 := `Create these files: +1. combo_d.go with content: package main; func ComboD() {} +2. combo_e.go with content: package main; func ComboE() {} +Create both files, nothing else.` + + result, err = env.RunAgent(prompt2) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("combo_d.go")) + require.True(t, env.FileExists("combo_e.go")) + + // User unstashes B, C + t.Log("Unstashing B, C") + env.GitStashPop() + + // Verify B, C are back + assert.True(t, env.FileExists("combo_b.go"), "combo_b.go should be back after unstash") + assert.True(t, env.FileExists("combo_c.go"), "combo_c.go should be back after unstash") + + // User commits ALL files together (B, C, D, E) + t.Log("Committing all remaining files together") + env.GitCommitWithShadowHooks("Add combo_b, combo_c, combo_d, combo_e", + "combo_b.go", "combo_c.go", "combo_d.go", "combo_e.go") + + // Verify checkpoint was created + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDs), 2, "Should have checkpoints") + t.Logf("Checkpoint IDs: %v", checkpointIDs) + + // The second commit should have all 4 files linked to a single checkpoint + // Verify metadata branch exists + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_Scenario7_PartialStagingWithGitAddP tests partial staging with git add -p. +// +// Scenario 7: Partial Staging with `git add -p` +// - Agent makes multiple changes to a single file +// - User stages only part of the changes (simulated with partial write) +// - Content-aware carry-forward detects partial commit +// - Remaining changes carried forward to next commit +func TestE2E_Scenario7_PartialStagingSimulated(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Agent creates a file with multiple functions + t.Log("Creating file with multiple functions") + multiLinePrompt := `Create a file called partial.go with this exact content: +package main + +func First() int { + return 1 +} + +func Second() int { + return 2 +} + +func Third() int { + return 3 +} + +func Fourth() int { + return 4 +} + +Create only this file with exactly this content.` + + result, err := env.RunAgent(multiLinePrompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("partial.go")) + + // Check rewind points before commit + pointsBefore := env.GetRewindPoints() + t.Logf("Rewind points before commit: %d", len(pointsBefore)) + + // Simulate partial staging by temporarily replacing file with partial content + // Save the full content + fullContent := env.ReadFile("partial.go") + t.Logf("Full content length: %d bytes", len(fullContent)) + + // Write partial content (only first two functions) + partialContent := `package main + +func First() int { + return 1 +} + +func Second() int { + return 2 +} +` + env.WriteFile("partial.go", partialContent) + + // Commit the partial content + t.Log("Committing partial content (First and Second functions)") + env.GitCommitWithShadowHooks("Add first two functions", "partial.go") + + // Verify first checkpoint was created + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDs), 1, "Should have first checkpoint") + t.Logf("First checkpoint ID: %s", checkpointIDs[0]) + + // Restore the full content (simulating remaining changes still in worktree) + env.WriteFile("partial.go", fullContent) + + // Commit the remaining content + t.Log("Committing full content (all functions)") + env.GitCommitWithShadowHooks("Add remaining functions", "partial.go") + + // Verify second checkpoint was created + checkpointIDsAfter := env.GetAllCheckpointIDsFromHistory() + require.GreaterOrEqual(t, len(checkpointIDsAfter), 2, "Should have two checkpoints") + t.Logf("Checkpoint IDs: %v", checkpointIDsAfter) + + // Each commit should have its own unique checkpoint ID + assert.NotEqual(t, checkpointIDsAfter[0], checkpointIDsAfter[1], + "Each commit should have its own unique checkpoint ID") +} + +// TestE2E_ContentAwareOverlap_RevertAndReplace tests content-aware overlap detection +// when user reverts session changes and writes different content. +// +// Content-Aware Overlap Detection: +// - Agent creates file X with content "hello" +// - User reverts X (git checkout -- X) +// - User writes completely different content +// - User commits +// - Content mismatch → NO checkpoint trailer added +func TestE2E_ContentAwareOverlap_RevertAndReplace(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Agent creates a file + t.Log("Agent creating file with specific content") + createPrompt := `Create a file called overlap_test.go with this exact content: +package main + +func OverlapOriginal() string { + return "original content from agent" +} + +Create only this file.` + + result, err := env.RunAgent(createPrompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + require.True(t, env.FileExists("overlap_test.go")) + originalContent := env.ReadFile("overlap_test.go") + t.Logf("Original content: %s", originalContent) + + // Verify rewind points exist (session tracked the change) + points := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points), 1, "Should have rewind points") + + // User reverts the file and writes completely different content + t.Log("User reverting file and writing different content") + differentContent := `package main + +func CompletelyDifferent() string { + return "user wrote this, not the agent" +} +` + env.WriteFile("overlap_test.go", differentContent) + + // Verify content is different + currentContent := env.ReadFile("overlap_test.go") + assert.NotEqual(t, originalContent, currentContent) + + // Commits before this test + commitsBefore := env.GetCommitCount() + + // User commits the different content + t.Log("Committing user-written content") + env.GitCommitWithShadowHooks("Add overlap test file", "overlap_test.go") + + // Verify commit was made + commitsAfter := env.GetCommitCount() + assert.Equal(t, commitsBefore+1, commitsAfter, "Should have one new commit") + + // Check for checkpoint trailer + // With content-aware overlap detection, if the user completely replaced + // the agent's content (new file with different hash), there should be + // NO checkpoint trailer added. This is documented in checkpoint-scenarios.md + // under "Content-Aware Overlap Detection". + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + t.Logf("Checkpoint IDs found: %v", checkpointIDs) + + // The first commit (Initial commit from NewFeatureBranchEnv) doesn't have a checkpoint. + // Only agent-assisted commits should have checkpoint trailers. + // When user completely replaces agent content, content-aware detection should + // prevent the checkpoint trailer from being added. + // + // Per docs/architecture/checkpoint-scenarios.md: + // - New file + content hash mismatch → No overlap → No checkpoint trailer + assert.Empty(t, checkpointIDs, + "Content-aware detection should prevent checkpoint trailer when user completely replaces agent content") +} + +// TestE2E_Scenario1_BasicFlow verifies the simplest workflow matches the documented scenario. +// +// Scenario 1: Prompt → Changes → Prompt Finishes → User Commits +// This test explicitly verifies the documented flow. +func TestE2E_Scenario1_BasicFlow(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. User submits prompt (triggers UserPromptSubmit hook → InitializeSession) + t.Log("Step 1: User submits prompt") + + // 2. Claude makes changes (creates files) + prompt := `Create a file called scenario1.go with this content: +package main +func Scenario1() {} +Create only this file.` + + result, err := env.RunAgent(prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify file was created + require.True(t, env.FileExists("scenario1.go")) + + // 3. After stop hook: checkpoint is saved, FilesTouched is set + t.Log("Step 3: Checking rewind points after stop") + points := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points), 1, "Should have rewind point after stop") + t.Logf("Rewind points: %d", len(points)) + + // 4. User commits (triggers prepare-commit-msg and post-commit hooks) + t.Log("Step 4: User commits") + env.GitCommitWithShadowHooks("Add scenario1 file", "scenario1.go") + + // 5. Verify checkpoint was created with trailer + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") + assert.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") + t.Logf("Checkpoint ID: %s", checkpointID) + + // 6. Verify shadow branch was cleaned up and metadata branch exists + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist after condensation") +} + +// TestE2E_Scenario2_AgentCommitsDuringTurn verifies the deferred finalization flow. +// +// Scenario 2: Prompt Commits Within Single Turn +// - Agent commits during ACTIVE phase +// - PostCommit saves provisional transcript +// - HandleTurnEnd (Stop hook) finalizes with full transcript +func TestE2E_Scenario2_AgentCommitsDuringTurn(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + commitsBefore := env.GetCommitCount() + + // Agent creates file and commits it + t.Log("Agent creating file and committing") + commitPrompt := `Create a file called agent_commit.go with this content: +package main +func AgentCommit() {} + +Then commit it with: git add agent_commit.go && git commit -m "Agent adds file" + +Create the file first, then run the git commands.` + + result, err := env.RunAgentWithTools(commitPrompt, []string{"Write", "Bash"}) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify file was created + require.True(t, env.FileExists("agent_commit.go")) + + // Verify commit was made + commitsAfter := env.GetCommitCount() + assert.Greater(t, commitsAfter, commitsBefore, "Agent should have made a commit") + + // Check commit message + headMsg := env.GetCommitMessage(env.GetHeadHash()) + t.Logf("HEAD commit message: %s", headMsg) + + // Check for checkpoint trailer + checkpointIDs := env.GetAllCheckpointIDsFromHistory() + t.Logf("Checkpoint IDs: %v", checkpointIDs) + + // Verify metadata branch exists (if checkpoint was created) + if len(checkpointIDs) > 0 { + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") + } +} diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 85837c257..b0ee90a66 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -527,3 +527,114 @@ func (env *TestEnv) RunAgentWithTools(prompt string, tools []string) (*AgentResu //nolint:wrapcheck // test helper, caller handles error return env.Agent.RunPromptWithTools(context.Background(), env.RepoDir, prompt, tools) } + +// GitStash runs git stash to save uncommitted changes. +func (env *TestEnv) GitStash() { + env.T.Helper() + + //nolint:noctx // test code, no context needed for git stash + cmd := exec.Command("git", "stash") + cmd.Dir = env.RepoDir + if output, err := cmd.CombinedOutput(); err != nil { + env.T.Fatalf("git stash failed: %v\nOutput: %s", err, output) + } +} + +// GitStashPop runs git stash pop to restore stashed changes. +func (env *TestEnv) GitStashPop() { + env.T.Helper() + + //nolint:noctx // test code, no context needed for git stash pop + cmd := exec.Command("git", "stash", "pop") + cmd.Dir = env.RepoDir + if output, err := cmd.CombinedOutput(); err != nil { + env.T.Fatalf("git stash pop failed: %v\nOutput: %s", err, output) + } +} + +// GitCheckoutFile reverts a file to its committed state. +func (env *TestEnv) GitCheckoutFile(path string) { + env.T.Helper() + + //nolint:gosec,noctx // test code, path is from test setup, no context needed + cmd := exec.Command("git", "checkout", "--", path) + cmd.Dir = env.RepoDir + if output, err := cmd.CombinedOutput(); err != nil { + env.T.Fatalf("git checkout -- %s failed: %v\nOutput: %s", path, err, output) + } +} + +// DeleteFile removes a file from the test repo. +func (env *TestEnv) DeleteFile(path string) { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + if err := os.Remove(fullPath); err != nil { + env.T.Fatalf("failed to delete file %s: %v", path, err) + } +} + +// GetCommitCount returns the number of commits on the current branch. +func (env *TestEnv) GetCommitCount() int { + 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) + } + + count := 0 + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + commitIter.ForEach(func(c *object.Commit) error { + count++ + return nil + }) + + return count +} + +// GetAllCheckpointIDsFromHistory walks backwards from HEAD and returns +// all checkpoint IDs from commits with Entire-Checkpoint trailers. +func (env *TestEnv) GetAllCheckpointIDsFromHistory() []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 checkpointIDs []string + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + commitIter.ForEach(func(c *object.Commit) error { + for _, line := range strings.Split(c.Message, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Entire-Checkpoint:") { + id := strings.TrimSpace(strings.TrimPrefix(line, "Entire-Checkpoint:")) + checkpointIDs = append(checkpointIDs, id) + } + } + return nil + }) + + return checkpointIDs +} From 3abe69255cec0860aafb6b4a28efe25960bf08b2 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 22:57:35 +0100 Subject: [PATCH 17/22] review feedback, one more case tested Entire-Checkpoint: d51bd3632c2d --- .../deferred_finalization_test.go | 101 +++++++++++++++++- cmd/entire/cli/session/phase.go | 3 +- cmd/entire/cli/session/state.go | 21 ++-- cmd/entire/cli/strategy/content_overlap.go | 8 +- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index 5e71962ce..d0e0b40a6 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -133,6 +133,13 @@ func TestShadow_DeferredTranscriptFinalization(t *testing.T) { // Read the provisional transcript transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) + + // Verify the path structure matches expected sharded format: //0/full.jsonl + expectedPrefix := checkpointID[:2] + "/" + checkpointID[2:] + "/0/" + if !strings.HasPrefix(transcriptPath, expectedPrefix) { + t.Errorf("Unexpected path structure: got %s, expected prefix %s", transcriptPath, expectedPrefix) + } + provisionalContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) if !found { t.Fatalf("Provisional transcript should exist at %s", transcriptPath) @@ -455,8 +462,10 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { len(state.TurnCheckpointIDs), state.TurnCheckpointIDs) } - // Add more work to transcript before stopping - sess.TranscriptBuilder.AddAssistantMessage("All files created successfully!") + // Add more work to transcript before stopping. + // Use a constant so the assertion below stays in sync with this message. + const finalMessage = "All files created successfully!" + sess.TranscriptBuilder.AddAssistantMessage(finalMessage) if err := sess.TranscriptBuilder.WriteToFile(sess.TranscriptPath); err != nil { t.Fatalf("Failed to write transcript: %v", err) } @@ -488,9 +497,9 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { t.Errorf("Checkpoint %d transcript should exist at %s", i, transcriptPath) continue } - // All transcripts should contain the final message - if !strings.Contains(content, "All files created successfully") { - t.Errorf("Checkpoint %d transcript should be finalized with final message", i) + // All transcripts should contain the final message (same constant used above) + if !strings.Contains(content, finalMessage) { + t.Errorf("Checkpoint %d transcript should be finalized with final message %q", i, finalMessage) } } @@ -770,3 +779,85 @@ func TestShadow_RevertedFiles_ManualEditNoCheckpoint(t *testing.T) { 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 "entire 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, strategy.StrategyNameManualCommit) + + sess := env.NewSession() + + // Start session (ACTIVE) + if err := env.SimulateUserPromptSubmitWithTranscriptPath(sess.ID, 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") +} diff --git a/cmd/entire/cli/session/phase.go b/cmd/entire/cli/session/phase.go index 417aa454b..6b5b9fc46 100644 --- a/cmd/entire/cli/session/phase.go +++ b/cmd/entire/cli/session/phase.go @@ -25,7 +25,8 @@ var allPhases = []Phase{PhaseIdle, PhaseActive, PhaseEnded} func PhaseFromString(s string) Phase { switch Phase(s) { case PhaseActive, "active_committed": - // "active_committed" was removed but meant "agent active + commit happened". + // "active_committed" was removed with the 1:1 checkpoint model. + // It previously meant "agent active + commit happened during turn". // Normalize to ACTIVE so HandleTurnEnd can finalize any pending checkpoints. return PhaseActive case PhaseIdle: diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 2eb1b6393..95f69d03f 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -62,14 +62,20 @@ type State struct { Phase Phase `json:"phase,omitempty"` // TurnID is a unique identifier for the current agent turn. - // Generated at turn start, shared across all checkpoints within the same turn. - // Used to correlate related checkpoints when a turn's work spans multiple commits. + // Lifecycle: + // - Generated fresh in InitializeSession at each turn start + // - Shared across all checkpoints within the same turn + // - Used to correlate related checkpoints when a turn's work spans multiple commits + // - Persists until the next InitializeSession call generates a new one TurnID string `json:"turn_id,omitempty"` // TurnCheckpointIDs tracks all checkpoint IDs condensed during the current turn. - // Set in PostCommit when a checkpoint is condensed for an ACTIVE session. - // Consumed in HandleTurnEnd to finalize all checkpoints with the full transcript. - // Cleared in InitializeSession when a new prompt starts. + // Lifecycle: + // - Set in PostCommit when a checkpoint is condensed for an ACTIVE session + // - Consumed in HandleTurnEnd to finalize all checkpoints with the full transcript + // - Cleared in HandleTurnEnd after finalization completes + // - Cleared in InitializeSession when a new prompt starts + // - Cleared when session is reset (ResetSession deletes the state file entirely) TurnCheckpointIDs []string `json:"turn_checkpoint_ids,omitempty"` // LastInteractionTime is updated on every hook invocation. @@ -161,8 +167,9 @@ type PromptAttribution struct { // NormalizeAfterLoad applies backward-compatible migrations to state loaded from disk. // Call this after deserializing a State from JSON. func (s *State) NormalizeAfterLoad() { - // Normalize legacy phase values. "active_committed" was removed in favor of - // the state machine handling commits during ACTIVE phase. + // Normalize legacy phase values. "active_committed" was removed with the + // 1:1 checkpoint model in favor of the state machine handling commits + // during ACTIVE phase with immediate condensation. if s.Phase == "active_committed" { logCtx := logging.WithComponent(context.Background(), "session") logging.Info(logCtx, "migrating legacy active_committed phase to active", diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index 9f6a434a5..3e3c2600f 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -94,10 +94,14 @@ func filesOverlapWithContent(repo *git.Repository, shadowBranchName string, head // Check each file in filesTouched for _, filePath := range filesTouched { - // Get file from HEAD tree + // Get file from HEAD tree (the committed content) headFile, err := headTree.File(filePath) if err != nil { - // File not in HEAD commit - doesn't count as overlap + // File not in HEAD commit. This happens when: + // - The session created/modified the file but user deleted it before committing + // - The file was staged as a deletion (git rm) + // In both cases, the session's work on this file is not in the commit, + // so it doesn't contribute to overlap. Continue checking other files. continue } From 5118ce106935284b8ad557991e4e7b7c6f73bca3 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Sat, 14 Feb 2026 23:43:05 +0100 Subject: [PATCH 18/22] add checkpoint validation including transcript check Entire-Checkpoint: 38ecf75f2f1d --- .../scenario_checkpoint_workflows_test.go | 121 +++++++++ cmd/entire/cli/e2e_test/testenv.go | 205 +++++++++++++++ .../deferred_finalization_test.go | 74 ++++-- .../manual_commit_workflow_test.go | 50 +--- cmd/entire/cli/integration_test/testenv.go | 239 ++++++++++++++++++ 5 files changed, 632 insertions(+), 57 deletions(-) diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go index 1c1f7d3ad..24045cc63 100644 --- a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go @@ -3,6 +3,7 @@ package e2e import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -83,6 +84,25 @@ Do each task in order, making the commit after each file creation.` // Verify metadata branch exists assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist") + + // Validate each checkpoint has proper metadata and content + // Note: checkpointIDs are in reverse chronological order (newest first) + // So checkpointIDs[0] = file3.go, [1] = file2.go, [2] = file1.go + // + // With deferred finalization, all checkpoints from the same turn get the + // FULL transcript at turn end, so all checkpoints should contain all file names. + allFiles := []string{"file1.go", "file2.go", "file3.go"} + for i, cpID := range checkpointIDs { + fileNum := len(checkpointIDs) - i // Reverse the index to match file numbers + fileName := fmt.Sprintf("file%d.go", fileNum) + t.Logf("Validating checkpoint %d: %s (files_touched: %s)", i, cpID, fileName) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: cpID, + Strategy: "manual-commit", + FilesTouched: []string{fileName}, + ExpectedTranscriptContent: allFiles, // All checkpoints have full transcript + }) + } } // TestE2E_Scenario4_UserSplitsCommits tests user splitting agent changes into multiple commits. @@ -153,6 +173,26 @@ Create all four files, no other files or actions.` // Verify metadata branch exists assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist") + + // Both checkpoints are from the same session where agent created all 4 files. + // The transcript should contain all file names since it's the same agent work. + allFiles := []string{"fileA.go", "fileB.go", "fileC.go", "fileD.go"} + + // Validate first checkpoint (files A, B committed) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointAB, + Strategy: "manual-commit", + FilesTouched: []string{"fileA.go", "fileB.go"}, + ExpectedTranscriptContent: allFiles, // Full session transcript + }) + + // Validate second checkpoint (files C, D committed) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointCD, + Strategy: "manual-commit", + FilesTouched: []string{"fileC.go", "fileD.go"}, + ExpectedTranscriptContent: allFiles, // Full session transcript + }) } // TestE2E_Scenario5_PartialCommitStashNextPrompt tests partial commit, stash, then new prompt. @@ -223,6 +263,28 @@ Create both files, nothing else.` // Verify metadata branch exists assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist") + + // Validate checkpoints have proper metadata and transcripts + // checkpointIDs[0] is the most recent (D, E commit from prompt 2) + // checkpointIDs[1] is the earlier commit (A only from prompt 1) + // + // These are from DIFFERENT sessions (prompt 1 vs prompt 2), so each has + // its own transcript. Prompt 1 created A, B, C (B, C were stashed). + // Prompt 2 created D, E. + if len(checkpointIDs) >= 2 { + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDs[0], + Strategy: "manual-commit", + FilesTouched: []string{"stash_d.go", "stash_e.go"}, + ExpectedTranscriptContent: []string{"stash_d.go", "stash_e.go"}, // Prompt 2 transcript + }) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDs[1], + Strategy: "manual-commit", + FilesTouched: []string{"stash_a.go"}, + ExpectedTranscriptContent: []string{"stash_a.go", "stash_b.go", "stash_c.go"}, // Full prompt 1 transcript + }) + } } // TestE2E_Scenario6_StashSecondPromptUnstashCommitAll tests stash, new prompt, unstash, commit all. @@ -304,6 +366,30 @@ Create both files, nothing else.` // Verify metadata branch exists assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist") + + // Validate checkpoints have proper metadata and transcripts + // checkpointIDs[0] is the most recent (B, C, D, E combined commit) + // checkpointIDs[1] is the earlier commit (A only) + // + // Prompt 1 created A, B, C. User committed A, then stashed B, C. + // Prompt 2 created D, E. User unstashed B, C, then committed all 4 together. + if len(checkpointIDs) >= 2 { + // The BCDE commit happens during prompt 2's session, so its transcript + // contains prompt 2's work (D, E). B, C are included via carry-forward. + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDs[0], + Strategy: "manual-commit", + FilesTouched: []string{"combo_b.go", "combo_c.go", "combo_d.go", "combo_e.go"}, + ExpectedTranscriptContent: []string{"combo_d.go", "combo_e.go"}, // Prompt 2 transcript + }) + // The A commit has full prompt 1 transcript (A, B, C were all created) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDs[1], + Strategy: "manual-commit", + FilesTouched: []string{"combo_a.go"}, + ExpectedTranscriptContent: []string{"combo_a.go", "combo_b.go", "combo_c.go"}, // Full prompt 1 transcript + }) + } } // TestE2E_Scenario7_PartialStagingWithGitAddP tests partial staging with git add -p. @@ -393,6 +479,25 @@ func Second() int { // Each commit should have its own unique checkpoint ID assert.NotEqual(t, checkpointIDsAfter[0], checkpointIDsAfter[1], "Each commit should have its own unique checkpoint ID") + + // Validate checkpoints have proper metadata and transcripts + // checkpointIDsAfter[0] is the most recent (full content commit) + // checkpointIDsAfter[1] is the earlier commit (partial content) + // + // Both commits are from the same session (single prompt), so both have + // the same full transcript referencing partial.go and the function names. + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDsAfter[0], + Strategy: "manual-commit", + FilesTouched: []string{"partial.go"}, + ExpectedTranscriptContent: []string{"partial.go", "First", "Second", "Third", "Fourth"}, + }) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDsAfter[1], + Strategy: "manual-commit", + FilesTouched: []string{"partial.go"}, + ExpectedTranscriptContent: []string{"partial.go", "First", "Second", "Third", "Fourth"}, + }) } // TestE2E_ContentAwareOverlap_RevertAndReplace tests content-aware overlap detection @@ -520,6 +625,14 @@ Create only this file.` // 6. Verify shadow branch was cleaned up and metadata branch exists assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist after condensation") + + // 7. Validate checkpoint has proper metadata and transcript + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointID, + Strategy: "manual-commit", + FilesTouched: []string{"scenario1.go"}, + ExpectedTranscriptContent: []string{"scenario1.go"}, + }) } // TestE2E_Scenario2_AgentCommitsDuringTurn verifies the deferred finalization flow. @@ -568,5 +681,13 @@ Create the file first, then run the git commands.` if len(checkpointIDs) > 0 { assert.True(t, env.BranchExists("entire/checkpoints/v1"), "entire/checkpoints/v1 branch should exist") + + // Validate checkpoint has proper metadata and transcript + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointIDs[0], + Strategy: "manual-commit", + FilesTouched: []string{"agent_commit.go"}, + ExpectedTranscriptContent: []string{"agent_commit.go"}, + }) } } diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index b0ee90a66..6ea4c9bc7 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -4,6 +4,8 @@ package e2e import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "os" @@ -638,3 +640,206 @@ func (env *TestEnv) GetAllCheckpointIDsFromHistory() []string { return checkpointIDs } + +// 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 +} + +// CheckpointValidation contains expected values for checkpoint validation. +type CheckpointValidation struct { + // CheckpointID is the expected checkpoint ID + CheckpointID string + + // Strategy is the expected strategy name (optional) + Strategy string + + // FilesTouched are the expected files in files_touched (optional) + FilesTouched []string + + // ExpectedPrompts are strings that should appear in prompt.txt (optional) + ExpectedPrompts []string + + // ExpectedTranscriptContent are strings that should appear in full.jsonl (optional) + ExpectedTranscriptContent []string +} + +// ValidateCheckpoint performs comprehensive validation of a checkpoint on the metadata branch. +func (env *TestEnv) ValidateCheckpoint(v CheckpointValidation) { + env.T.Helper() + + metadataBranch := "entire/checkpoints/v1" + + // Compute sharded path: // + if len(v.CheckpointID) < 3 { + env.T.Fatalf("Checkpoint ID too short: %s", v.CheckpointID) + } + shardedPath := v.CheckpointID[:2] + "/" + v.CheckpointID[2:] + + // Validate root metadata.json exists and has expected fields + summaryPath := shardedPath + "/metadata.json" + summaryContent, found := env.ReadFileFromBranch(metadataBranch, summaryPath) + if !found { + env.T.Errorf("CheckpointSummary not found at %s", summaryPath) + } else { + var summary map[string]any + if err := json.Unmarshal([]byte(summaryContent), &summary); err != nil { + env.T.Errorf("Failed to parse CheckpointSummary: %v", err) + } else { + // Validate checkpoint_id + if cpID, ok := summary["checkpoint_id"].(string); !ok || cpID != v.CheckpointID { + env.T.Errorf("CheckpointSummary.checkpoint_id = %v, want %s", summary["checkpoint_id"], v.CheckpointID) + } + // Validate strategy if specified + if v.Strategy != "" { + if strategy, ok := summary["strategy"].(string); !ok || strategy != v.Strategy { + env.T.Errorf("CheckpointSummary.strategy = %v, want %s", summary["strategy"], v.Strategy) + } + } + // Validate sessions array exists + if sessions, ok := summary["sessions"].([]any); !ok || len(sessions) == 0 { + env.T.Error("CheckpointSummary.sessions should have at least one entry") + } + // Validate files_touched if specified + if len(v.FilesTouched) > 0 { + if filesTouched, ok := summary["files_touched"].([]any); ok { + touchedSet := make(map[string]bool) + for _, f := range filesTouched { + if s, ok := f.(string); ok { + touchedSet[s] = true + } + } + for _, expected := range v.FilesTouched { + if !touchedSet[expected] { + env.T.Errorf("CheckpointSummary.files_touched missing %q", expected) + } + } + } + } + } + } + + // Validate session metadata.json exists + sessionMetadataPath := shardedPath + "/0/metadata.json" + sessionContent, found := env.ReadFileFromBranch(metadataBranch, sessionMetadataPath) + if !found { + env.T.Errorf("Session metadata not found at %s", sessionMetadataPath) + } else { + var metadata map[string]any + if err := json.Unmarshal([]byte(sessionContent), &metadata); err != nil { + env.T.Errorf("Failed to parse session metadata: %v", err) + } else { + // Validate checkpoint_id matches + if cpID, ok := metadata["checkpoint_id"].(string); !ok || cpID != v.CheckpointID { + env.T.Errorf("Session metadata.checkpoint_id = %v, want %s", metadata["checkpoint_id"], v.CheckpointID) + } + // Validate created_at exists + if _, ok := metadata["created_at"].(string); !ok { + env.T.Error("Session metadata.created_at should exist") + } + } + } + + // Validate transcript is valid JSONL + transcriptPath := shardedPath + "/0/full.jsonl" + transcriptContent, found := env.ReadFileFromBranch(metadataBranch, transcriptPath) + if !found { + env.T.Errorf("Transcript not found at %s", transcriptPath) + } else { + // Check each line is valid JSON + lines := strings.Split(transcriptContent, "\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", i+1, err) + } + } + if validLines == 0 { + env.T.Error("Transcript is empty (no valid JSONL lines)") + } + // Check expected content + for _, expected := range v.ExpectedTranscriptContent { + if !strings.Contains(transcriptContent, expected) { + env.T.Errorf("Transcript should contain %q", expected) + } + } + } + + // Validate prompt.txt contains expected prompts + if len(v.ExpectedPrompts) > 0 { + promptPath := shardedPath + "/0/prompt.txt" + promptContent, found := env.ReadFileFromBranch(metadataBranch, promptPath) + if !found { + env.T.Errorf("Prompt file not found at %s", promptPath) + } else { + for _, expected := range v.ExpectedPrompts { + if !strings.Contains(promptContent, expected) { + env.T.Errorf("Prompt file should contain %q", expected) + } + } + } + } + + // Validate content hash exists and matches transcript + hashPath := shardedPath + "/0/content_hash.txt" + hashContent, found := env.ReadFileFromBranch(metadataBranch, hashPath) + if !found { + env.T.Errorf("Content hash not found at %s", hashPath) + } else if transcriptContent != "" { + // Verify hash matches + hash := sha256.Sum256([]byte(transcriptContent)) + expectedHash := "sha256:" + hex.EncodeToString(hash[:]) + storedHash := strings.TrimSpace(hashContent) + if storedHash != expectedHash { + env.T.Errorf("Content hash mismatch:\n stored: %s\n expected: %s", storedHash, expectedHash) + } + } +} diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index d0e0b40a6..45179f575 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -211,6 +211,21 @@ func TestShadow_DeferredTranscriptFinalization(t *testing.T) { t.Errorf("TurnCheckpointIDs should be cleared after finalization, got %v", state.TurnCheckpointIDs) } + // Comprehensive checkpoint validation + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointID, + SessionID: sess.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"feature.go"}, + ExpectedPrompts: []string{"Create feature function"}, + ExpectedTranscriptContent: []string{ + "Create feature function", // Initial user message + "Also add a helper function", // Post-commit user message + "helper.go", // Tool use for helper file + "Done with both changes!", // Final assistant message + }, + }) + t.Log("DeferredTranscriptFinalization test completed successfully") } @@ -299,16 +314,29 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { firstCheckpointID, secondCheckpointID) } - // Verify both checkpoints exist on metadata branch - firstPath := CheckpointSummaryPath(firstCheckpointID) - if !env.FileExistsInBranch(paths.MetadataBranchName, firstPath) { - t.Errorf("First checkpoint metadata should exist at %s", firstPath) - } + // Validate first checkpoint (file A only) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: firstCheckpointID, + SessionID: sess.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"fileA.go"}, + ExpectedPrompts: []string{"Create files A, B, and C"}, + ExpectedTranscriptContent: []string{ + "Create files A, B, and C", + }, + }) - secondPath := CheckpointSummaryPath(secondCheckpointID) - if !env.FileExistsInBranch(paths.MetadataBranchName, secondPath) { - t.Errorf("Second checkpoint metadata should exist at %s", secondPath) - } + // Validate second checkpoint (file B) + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: secondCheckpointID, + SessionID: sess.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"fileB.go"}, + ExpectedPrompts: []string{"Create files A, B, and C"}, + ExpectedTranscriptContent: []string{ + "Create files A, B, and C", + }, + }) t.Log("CarryForward_ActiveSession test completed successfully") } @@ -489,18 +517,24 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { t.Errorf("TurnCheckpointIDs should be cleared, got %v", state.TurnCheckpointIDs) } - // Verify all checkpoints exist and have finalized transcripts + // Validate all 3 checkpoints with comprehensive checks + expectedFiles := [][]string{ + {"fileA.go"}, + {"fileB.go"}, + {"fileC.go"}, + } for i, cpID := range checkpointIDs { - transcriptPath := SessionFilePath(cpID, paths.TranscriptFileName) - content, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) - if !found { - t.Errorf("Checkpoint %d transcript should exist at %s", i, transcriptPath) - continue - } - // All transcripts should contain the final message (same constant used above) - if !strings.Contains(content, finalMessage) { - t.Errorf("Checkpoint %d transcript should be finalized with final message %q", i, finalMessage) - } + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: cpID, + SessionID: sess.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: expectedFiles[i], + ExpectedPrompts: []string{"Create files A, B, and C"}, + ExpectedTranscriptContent: []string{ + "Create files A, B, and C", // Initial prompt + finalMessage, // Final message (added after stop) + }, + }) } t.Log("MultipleCommits_SameActiveTurn test completed successfully") diff --git a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go index 2c747bbd3..2b486accb 100644 --- a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go +++ b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go @@ -673,44 +673,20 @@ func TestShadow_TranscriptCondensation(t *testing.T) { t.Fatal("entire/checkpoints/v1 branch should exist after condensation") } - // Verify root metadata.json (CheckpointSummary) exists - summaryPath := CheckpointSummaryPath(checkpointID) - if !env.FileExistsInBranch(paths.MetadataBranchName, summaryPath) { - t.Errorf("root metadata.json should exist at %s", summaryPath) - } - - // Verify transcript file exists in session subdirectory (new format: 0/full.jsonl) - transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) - if !env.FileExistsInBranch(paths.MetadataBranchName, transcriptPath) { - t.Errorf("Transcript (%s) should exist at %s", paths.TranscriptFileName, transcriptPath) - } else { - t.Log("✓ Transcript file exists in checkpoint") - } - - // Verify content_hash.txt exists in session subdirectory - hashPath := SessionFilePath(checkpointID, "content_hash.txt") - if !env.FileExistsInBranch(paths.MetadataBranchName, hashPath) { - t.Errorf("content_hash.txt should exist at %s", hashPath) - } - - // Verify root metadata.json can be read and parsed as CheckpointSummary - summaryContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, summaryPath) - if !found { - t.Fatal("root metadata.json should be readable") - } - var summary checkpoint.CheckpointSummary - if err := json.Unmarshal([]byte(summaryContent), &summary); err != nil { - t.Fatalf("failed to parse root metadata.json as CheckpointSummary: %v", err) - } - - // Verify Sessions array is populated - if len(summary.Sessions) == 0 { - t.Errorf("CheckpointSummary.Sessions should have at least one entry") - } else { - t.Logf("✓ CheckpointSummary has %d session(s)", len(summary.Sessions)) - } + // Comprehensive checkpoint validation + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointID, + SessionID: session.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"main.go"}, + ExpectedPrompts: []string{"Create main.go with hello world"}, + ExpectedTranscriptContent: []string{ + "Create main.go with hello world", + "main.go", + }, + }) - // Verify agent field is in session-level metadata (not root summary) + // Additionally verify agent field in session metadata sessionMetadataPath := SessionFilePath(checkpointID, paths.MetadataFileName) sessionMetadataContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, sessionMetadataPath) if !found { diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 24035ac57..224a58854 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -3,6 +3,8 @@ package integration import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "os" @@ -1313,6 +1315,243 @@ 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 JSONL. +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) + } + + // Validate it's valid JSONL (each non-empty line should be valid JSON) + 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 JSONL lines)") + } + + // 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) + } + } +} + func findModuleRoot() string { // Start from this source file's location and walk up to find go.mod _, thisFile, _, ok := runtime.Caller(0) From 8af0e74d512d8d815464eee59ee68daeb32945df Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Sun, 15 Feb 2026 14:28:40 +1100 Subject: [PATCH 19/22] align checkpoint update message format --- cmd/entire/cli/checkpoint/committed.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 82836804b..ba11076c2 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1119,7 +1119,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti } authorName, authorEmail := getGitAuthorFromRepo(s.repo) - commitMsg := fmt.Sprintf("Finalize transcript for checkpoint %s", opts.CheckpointID) + commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID) newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) if err != nil { return err From 50e80758e3144667e7605c2e6fc9c795a436f235 Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Sun, 15 Feb 2026 15:13:06 +1100 Subject: [PATCH 20/22] fix: consolidate GetGitAuthorFromRepo and add global config fallback The checkpoint package's private getGitAuthorFromRepo only read local repo config, missing global ~/.gitconfig. This caused "Finalize transcript" commits on entire/checkpoints/v1 to have author "Unknown " when git user is configured globally. Consolidated to a single exported GetGitAuthorFromRepo in the checkpoint package (with global config fallback), and made the strategy package delegate to it. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 1274eaa17eb8 --- .gitignore | 3 +- cmd/entire/cli/checkpoint/committed.go | 25 ++- .../cli/checkpoint/committed_update_test.go | 194 ++++++++++++++++++ cmd/entire/cli/strategy/common.go | 40 +--- 4 files changed, 221 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index dfc85a1e6..2e0dde372 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ entire-test test_claude.txt cmd/entire/entire docs/requirements -docs/plans \ No newline at end of file +docs/plans +docs/reviews \ No newline at end of file diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index ba11076c2..a53265c08 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -26,6 +26,7 @@ import ( "github.com/entireio/cli/redact" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" @@ -993,7 +994,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint return err } - authorName, authorEmail := getGitAuthorFromRepo(s.repo) + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID) newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) if err != nil { @@ -1118,7 +1119,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti return err } - authorName, authorEmail := getGitAuthorFromRepo(s.repo) + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID) newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) if err != nil { @@ -1195,7 +1196,7 @@ func (s *GitStore) ensureSessionsBranch() error { return err } - authorName, authorEmail := getGitAuthorFromRepo(s.repo) + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail) if err != nil { return err @@ -1359,8 +1360,9 @@ func createRedactedBlobFromFile(repo *git.Repository, filePath, treePath string) return hash, mode, nil } -// getGitAuthorFromRepo retrieves the git user.name and user.email from the repository config. -func getGitAuthorFromRepo(repo *git.Repository) (name, email string) { +// 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) { // Get repository config (includes local settings) cfg, err := repo.Config() if err == nil { @@ -1368,6 +1370,19 @@ func getGitAuthorFromRepo(repo *git.Repository) (name, email string) { email = cfg.User.Email } + // If not found in local config, try global config + if name == "" || email == "" { + 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" diff --git a/cmd/entire/cli/checkpoint/committed_update_test.go b/cmd/entire/cli/checkpoint/committed_update_test.go index 6470fd270..4c139c058 100644 --- a/cmd/entire/cli/checkpoint/committed_update_test.go +++ b/cmd/entire/cli/checkpoint/committed_update_test.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" ) @@ -417,3 +418,196 @@ func TestState_TurnCheckpointIDs_JSON(t *testing.T) { t.Errorf("expected empty JSON, got %s", string(data)) } } + +// TestUpdateCommitted_UsesCorrectAuthor verifies that the "Finalize transcript" +// commit on entire/checkpoints/v1 gets the correct author from global git config, +// not "Unknown ". +func TestUpdateCommitted_UsesCorrectAuthor(t *testing.T) { + // Cannot use t.Parallel() because subtests use t.Setenv. + + tests := []struct { + name string + localName string + localEmail string + globalName string + globalEmail string + wantName string + wantEmail string + }{ + { + name: "global config only", + globalName: "Global User", + globalEmail: "global@example.com", + wantName: "Global User", + wantEmail: "global@example.com", + }, + { + name: "local config takes precedence", + localName: "Local User", + localEmail: "local@example.com", + globalName: "Global User", + wantName: "Local User", + wantEmail: "local@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.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 repo + 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) + } + } + + // Create initial commit so repo has HEAD + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + readmeFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write README: %v", err) + } + if _, err := wt.Add("README.md"); err != nil { + t.Fatalf("failed to add README: %v", err) + } + if _, err := wt.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Setup", Email: "setup@test.com"}, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Write initial checkpoint + store := NewGitStore(repo) + cpID := id.MustCheckpointID("a1b2c3d4e5f6") + err = store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Strategy: "manual-commit", + Transcript: []byte("provisional\n"), + AuthorName: tt.wantName, + AuthorEmail: tt.wantEmail, + }) + if err != nil { + t.Fatalf("WriteCommitted() error = %v", err) + } + + // Call UpdateCommitted — this is the operation under test + err = store.UpdateCommitted(context.Background(), UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-001", + Transcript: []byte("full transcript\n"), + }) + if err != nil { + t.Fatalf("UpdateCommitted() error = %v", err) + } + + // Read the latest commit on entire/checkpoints/v1 and verify author + ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("failed to get sessions branch ref: %v", err) + } + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + t.Fatalf("failed to get commit: %v", err) + } + + if commit.Author.Name != tt.wantName { + t.Errorf("commit author name = %q, want %q", commit.Author.Name, tt.wantName) + } + if commit.Author.Email != tt.wantEmail { + t.Errorf("commit author email = %q, want %q", commit.Author.Email, tt.wantEmail) + } + }) + } +} + +// TestGetGitAuthorFromRepo_GlobalFallback verifies that GetGitAuthorFromRepo +// falls back to global git config when local config is empty. +func TestGetGitAuthorFromRepo_GlobalFallback(t *testing.T) { + // Cannot use t.Parallel() because we use t.Setenv. + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + // Write global .gitconfig with user info + globalCfg := "[user]\n\tname = Global Author\n\temail = global@test.com\n" + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalCfg), 0o644); err != nil { + t.Fatalf("failed to write global gitconfig: %v", err) + } + + // Create repo with NO local user config + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + name, email := GetGitAuthorFromRepo(repo) + if name != "Global Author" { + t.Errorf("name = %q, want %q", name, "Global Author") + } + if email != "global@test.com" { + t.Errorf("email = %q, want %q", email, "global@test.com") + } +} + +// TestGetGitAuthorFromRepo_NoConfig verifies defaults when no config exists. +func TestGetGitAuthorFromRepo_NoConfig(t *testing.T) { + // Cannot use t.Parallel() because we use t.Setenv. + + home := t.TempDir() // Empty home — no .gitconfig + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + name, email := GetGitAuthorFromRepo(repo) + if name != "Unknown" { + t.Errorf("name = %q, want %q", name, "Unknown") + } + if email != "unknown@local" { + t.Errorf("email = %q, want %q", email, "unknown@local") + } +} + +// Verify go-git config import is used (compile-time check). +var _ = config.GlobalScope diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 232acc9bb..9b8f2439d 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -20,7 +20,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" @@ -1449,41 +1448,12 @@ func getSessionDescriptionFromTree(tree *object.Tree, metadataDir string) string return NoDescription } -// GetGitAuthorFromRepo retrieves the git user.name and user.email from the repository config. -// It checks local config first, then falls back to global config. -// Returns ("Unknown", "unknown@local") if no user is configured - this allows -// operations to proceed even without git user config, which is especially useful -// for internal metadata commits on branches like entire/checkpoints/v1. +// 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) { - // Get repository config (includes local settings) - cfg, err := repo.Config() - if err == nil { - name = cfg.User.Name - email = cfg.User.Email - } - - // If not found in local config, try global config - if name == "" || email == "" { - 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 + return checkpoint.GetGitAuthorFromRepo(repo) } // GetCurrentBranchName returns the short name of the current branch if HEAD points to a branch. From 5ae3d9696eaa64e5af446988d66c12b31b176a0d Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Sun, 15 Feb 2026 16:16:47 +1100 Subject: [PATCH 21/22] review feedback: document invariants and add partial failure test - Document the coupling between phase transition and TurnCheckpointIDs guard (ACTIVE + GitCommit must stay ACTIVE for recording to work) - Document that TurnCheckpointIDs is intentionally preserved during carry-forward (those checkpoints still need finalization at turn end) - Add TestHandleTurnEnd_PartialFailure to lock best-effort behavior: valid checkpoints are finalized even when one ID is missing Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f449058a5889 --- .../cli/strategy/manual_commit_hooks.go | 6 ++ .../cli/strategy/phase_postcommit_test.go | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 4358b696a..c9063a547 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -640,6 +640,9 @@ func (s *ManualCommitStrategy) PostCommit() error { // 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 condensed && state.Phase.IsActive() { state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String()) } @@ -1812,6 +1815,9 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( state.StepCount = 1 state.CheckpointTranscriptStart = 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), diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 1afe204e9..331a34a8a 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -1168,6 +1168,88 @@ func TestPostCommit_IdleSession_DoesNotRecordTurnCheckpointIDs(t *testing.T) { "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 entire/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(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + state.TurnCheckpointIDs = nil + require.NoError(t, s.saveSessionState(state)) + + // First commit → creates real checkpoint on entire/checkpoints/v1 + commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") + require.NoError(t, s.PostCommit()) + + // Write new content and create a second real checkpoint + require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644)) + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) // refresh shadow branch + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + // Preserve TurnCheckpointIDs from the first commit + state.TurnCheckpointIDs = []string{"a1b2c3d4e5f6"} + require.NoError(t, s.saveSessionState(state)) + + commitFilesWithTrailer(t, repo, dir, "b2c3d4e5f6a1", "second.txt") + require.NoError(t, s.PostCommit()) + + // Verify we now have 2 real checkpoint IDs + state, err = s.loadSessionState(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 entire/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, ".entire", "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(state)) + + // Call HandleTurnEnd — should NOT return error (best-effort) + err = s.HandleTurnEnd(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) + } +} + // 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, From 345f0208efb519fc606aa5b0dee5fe976d8a9637 Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Sun, 15 Feb 2026 19:49:00 +1100 Subject: [PATCH 22/22] fix: include error in transcript read failure log The warning log when os.ReadFile fails during finalization omitted the underlying error, making stop-time failures hard to diagnose. Now includes the error as a structured field and distinguishes between read failures and empty transcripts. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 0e6b66910244 --- cmd/entire/cli/strategy/manual_commit_hooks.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c9063a547..aac8c66a3 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1623,9 +1623,14 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(state *SessionState) i fullTranscript, err := os.ReadFile(state.TranscriptPath) if err != nil || len(fullTranscript) == 0 { - logging.Warn(logCtx, "finalize: failed to read transcript, skipping", + 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