diff --git a/cmd/entire/cli/integration_test/carry_forward_overlap_test.go b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go new file mode 100644 index 000000000..2a48910ef --- /dev/null +++ b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go @@ -0,0 +1,220 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// TestCarryForward_NewSessionCommitDoesNotCondenseOldSession verifies that when +// an old session has carry-forward files and a NEW session commits unrelated files, +// the old session is NOT condensed into the new session's commit. +// +// This is a regression test for the bug where sessions with carry-forward files +// would be re-condensed into every subsequent commit indefinitely. +// +// This integration test complements the unit tests in phase_postcommit_test.go by +// testing the full hook invocation path with multiple sessions interacting. +// +// Scenario: +// 1. Session 1 creates file1.txt and file2.txt +// 2. User commits only file1.txt (partial commit) +// 3. Session 1 gets carry-forward: FilesTouched = ["file2.txt"] +// 4. Session 1 ends +// 5. Make some unrelated commits (simulating time passing) +// 6. New session 2 creates and commits file6.txt +// 7. Verify: Session 1 was NOT condensed into session 2's commit +// 8. Finally commit file2.txt +// 9. Verify: Session 1 IS condensed (carry-forward consumed) +func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + defer env.Cleanup() + + // ======================================== + // Setup + // ======================================== + env.InitRepo() + env.WriteFile("README.md", "# Test Repository") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/multi-session-carry-forward") + env.InitEntire(strategy.StrategyNameManualCommit) + + // ======================================== + // Phase 1: Session 1 creates files, partial commit, ends with carry-forward + // ======================================== + t.Log("Phase 1: Session 1 creates file1.txt and file2.txt") + + session1 := env.NewSession() + if err := env.SimulateUserPromptSubmit(session1.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit for session1 failed: %v", err) + } + + env.WriteFile("file1.txt", "content from session 1 - file 1") + env.WriteFile("file2.txt", "content from session 1 - file 2") + session1.CreateTranscript( + "Create file1 and file2", + []FileChange{ + {Path: "file1.txt", Content: "content from session 1 - file 1"}, + {Path: "file2.txt", Content: "content from session 1 - file 2"}, + }, + ) + if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { + t.Fatalf("SimulateStop for session1 failed: %v", err) + } + + // Partial commit - only file1.txt + t.Log("Phase 1b: Partial commit - only file1.txt") + env.GitAdd("file1.txt") + env.GitCommitWithShadowHooks("Partial commit: only file1", "file1.txt") + + // End session 1 + state1, err := env.GetSessionState(session1.ID) + if err != nil { + t.Fatalf("GetSessionState for session1 failed: %v", err) + } + state1.Phase = session.PhaseEnded + if err := env.WriteSessionState(session1.ID, state1); err != nil { + t.Fatalf("WriteSessionState for session1 failed: %v", err) + } + + // Verify carry-forward + state1, err = env.GetSessionState(session1.ID) + if err != nil { + t.Fatalf("GetSessionState for session1 failed: %v", err) + } + t.Logf("Session1 (ENDED) FilesTouched: %v", state1.FilesTouched) + + session1StepCount := state1.StepCount + + // ======================================== + // Phase 2: Make some unrelated commits (simulating time passing) + // ======================================== + t.Log("Phase 2: Making unrelated commits") + + for _, fileName := range []string{"file3.txt", "file4.txt"} { + env.WriteFile(fileName, "unrelated content") + env.GitAdd(fileName) + env.GitCommitWithShadowHooks("Add "+fileName, fileName) + } + + // ======================================== + // Phase 3: NEW session 2 starts and creates file6.txt + // ======================================== + t.Log("Phase 3: Session 2 starts and creates file6.txt") + + session2 := env.NewSession() + if err := env.SimulateUserPromptSubmit(session2.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit for session2 failed: %v", err) + } + + env.WriteFile("file6.txt", "content from session 2") + session2.CreateTranscript( + "Create file6", + []FileChange{{Path: "file6.txt", Content: "content from session 2"}}, + ) + if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil { + t.Fatalf("SimulateStop for session2 failed: %v", err) + } + + // Set session2 to ACTIVE + state2, err := env.GetSessionState(session2.ID) + if err != nil { + t.Fatalf("GetSessionState for session2 failed: %v", err) + } + state2.Phase = session.PhaseActive + if err := env.WriteSessionState(session2.ID, state2); err != nil { + t.Fatalf("WriteSessionState for session2 failed: %v", err) + } + + // ======================================== + // Phase 4: Commit file6.txt (session 2's file) + // ======================================== + t.Log("Phase 4: Committing file6.txt from session 2") + + env.GitAdd("file6.txt") + env.GitCommitWithShadowHooks("Add file6 from session 2", "file6.txt") + + finalHead := env.GetHeadHash() + + // ======================================== + // Phase 5: Verify session 1 was NOT condensed + // ======================================== + t.Log("Phase 5: Verifying session 1 (with carry-forward) was NOT condensed") + + state1After, err := env.GetSessionState(session1.ID) + if err != nil { + t.Fatalf("GetSessionState for session1 after session2 commit failed: %v", err) + } + + // StepCount should be unchanged + if state1After.StepCount != session1StepCount { + t.Errorf("Session 1 StepCount changed! Expected %d, got %d (incorrectly condensed into session 2's commit)", + session1StepCount, state1After.StepCount) + } + + // FilesTouched should still have file2.txt + hasFile2 := false + for _, f := range state1After.FilesTouched { + if f == "file2.txt" { + hasFile2 = true + break + } + } + if !hasFile2 { + t.Errorf("Session 1 FilesTouched was cleared! Expected file2.txt, got: %v", state1After.FilesTouched) + } + + t.Logf("Session 1 correctly preserved: StepCount=%d, FilesTouched=%v", state1After.StepCount, state1After.FilesTouched) + + // ======================================== + // Phase 6: Verify session 2 WAS condensed + // ======================================== + t.Log("Phase 6: Verifying session 2 WAS condensed") + + state2After, err := env.GetSessionState(session2.ID) + if err != nil { + t.Fatalf("GetSessionState for session2 after commit failed: %v", err) + } + + if state2After.BaseCommit != finalHead { + t.Errorf("Session 2 BaseCommit should be updated. Expected %s, got %s", + finalHead[:7], state2After.BaseCommit[:7]) + } + + // ======================================== + // Phase 7: Finally commit file2.txt (session 1's carry-forward file) + // ======================================== + t.Log("Phase 7: Committing file2.txt (session 1's carry-forward file)") + + env.GitAdd("file2.txt") + env.GitCommitWithShadowHooks("Add file2 (session 1 carry-forward)", "file2.txt") + + // ======================================== + // Phase 8: Verify session 1 WAS condensed this time + // ======================================== + t.Log("Phase 8: Verifying session 1 WAS condensed when its carry-forward file was committed") + + state1Final, err := env.GetSessionState(session1.ID) + if err != nil { + t.Fatalf("GetSessionState for session1 after file2 commit failed: %v", err) + } + + // StepCount should be reset to 0 (condensation happened) + if state1Final.StepCount != 0 { + t.Errorf("Session 1 StepCount should be 0 after condensation, got %d", state1Final.StepCount) + } + + // FilesTouched should be empty (carry-forward consumed) + if len(state1Final.FilesTouched) != 0 { + t.Errorf("Session 1 FilesTouched should be empty after condensation, got: %v", state1Final.FilesTouched) + } + + t.Log("Test completed successfully:") + t.Log(" - Session 1 NOT condensed into session 2's commit (file6.txt)") + t.Log(" - Session 1 WAS condensed when its own file (file2.txt) was committed") +} diff --git a/cmd/entire/cli/integration_test/mid_session_commit_test.go b/cmd/entire/cli/integration_test/mid_session_commit_test.go index 65437952e..c8147e8d1 100644 --- a/cmd/entire/cli/integration_test/mid_session_commit_test.go +++ b/cmd/entire/cli/integration_test/mid_session_commit_test.go @@ -210,3 +210,78 @@ func TestShadowStrategy_AgentCommit_AlwaysGetsTrailer(t *testing.T) { t.Logf("Agent commit correctly got checkpoint trailer: %s", checkpointID) } } + +// TestShadowStrategy_MidSessionCommit_FilesTouchedFallback tests that when +// FilesTouched is empty in session state (mid-session commit before SaveStep), +// the fallback to committedFiles works correctly and the checkpoint metadata +// contains the files that were actually committed. +// +// This is scenario 1 from the fix: when FilesTouched was originally empty, +// fallback should assign committedFiles to files_touched. +func TestShadowStrategy_MidSessionCommit_FilesTouchedFallback(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + session := env.NewSession() + + // Simulate user prompt with transcript path (initializes session with empty FilesTouched) + input := map[string]string{ + "session_id": session.ID, + "transcript_path": session.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) + } + + // Verify session state has empty FilesTouched (no SaveStep has been called) + 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 is nil") + } + if len(state.FilesTouched) != 0 { + t.Errorf("Session state FilesTouched should be empty before SaveStep, got: %v", state.FilesTouched) + } + + // Create a file as if Claude wrote it + env.WriteFile("mid_session_file.txt", "content from Claude mid-session") + + // Create transcript showing Claude created the file (NO Stop called, NO SaveStep called) + session.CreateTranscript("Create a file for testing fallback", []FileChange{ + {Path: "mid_session_file.txt", Content: "content from Claude mid-session"}, + }) + + // Commit mid-session - FilesTouched in session state is still empty + // The fallback should assign committedFiles to files_touched in the checkpoint metadata + env.GitCommitWithShadowHooks("Mid-session commit testing fallback", "mid_session_file.txt") + + // Get the checkpoint ID from the commit + commitHash := env.GetHeadHash() + checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash) + if checkpointID == "" { + t.Fatal("Mid-session commit should have Entire-Checkpoint trailer") + } + t.Logf("Mid-session commit has checkpoint ID: %s", checkpointID) + + // CRITICAL: Validate that the checkpoint metadata has the correct files_touched + // This verifies the fallback logic: when FilesTouched was empty, it should + // have been populated with the committed files. + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointID, + SessionID: session.ID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"mid_session_file.txt"}, + }) + + t.Log("FilesTouched fallback worked correctly: checkpoint metadata contains the committed file") +} diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index 9319257ce..bb9ffdb65 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -98,11 +98,19 @@ func filesOverlapWithContent(repo *git.Repository, shadowBranchName string, head // Get file from HEAD tree (the committed content) headFile, err := headTree.File(filePath) if err != nil { - // 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. + // File not in HEAD commit. Check if this is a deletion (existed in parent). + // Deletions count as overlap because the agent's action (deleting the file) + // is being committed - we want the session context linked to this commit. + if parentTree != nil { + if _, parentErr := parentTree.File(filePath); parentErr == nil { + // File existed in parent but not in HEAD - this is a deletion + logging.Debug(logCtx, "filesOverlapWithContent: deleted file counts as overlap", + slog.String("file", filePath), + ) + return true + } + } + // File didn't exist in parent either - not a deletion, skip continue } @@ -220,6 +228,8 @@ func stagedFilesOverlapWithContent(repo *git.Repository, shadowTree *object.Tree isModified := headErr == nil // Modified files always count as overlap (user edited session's work) + // This includes deletions - if file exists in HEAD and is being deleted, + // that's the agent's work being committed. if isModified { logging.Debug(logCtx, "stagedFilesOverlapWithContent: modified file counts as overlap", slog.String("file", stagedPath), diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go index 4e6b7e890..b9d9396b8 100644 --- a/cmd/entire/cli/strategy/content_overlap_test.go +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -178,6 +178,51 @@ func TestFilesOverlapWithContent_FileNotInCommit(t *testing.T) { assert.True(t, result, "File in commit with matching content should count as overlap") } +// TestFilesOverlapWithContent_DeletedFile tests that a deleted file +// (existed in parent, not in HEAD) DOES count as overlap. +// The agent's action of deleting the file is being committed. +func TestFilesOverlapWithContent_DeletedFile(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create and commit a file that will be deleted + toDelete := filepath.Join(dir, "to_delete.txt") + require.NoError(t, os.WriteFile(toDelete, []byte("content to delete"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("to_delete.txt") + require.NoError(t, err) + _, err = wt.Commit("Add file to delete", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // Create shadow branch (simulating agent work that includes the deletion) + createShadowBranchWithContent(t, repo, "del1234", "e3b0c4", map[string][]byte{ + "other.txt": []byte("other content"), + }) + + // Delete the file and commit the deletion + _, err = wt.Remove("to_delete.txt") + require.NoError(t, err) + deleteCommit, err := wt.Commit("Delete file", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(deleteCommit) + require.NoError(t, err) + + // Test: deleted file in filesTouched should count as overlap + shadowBranch := checkpoint.ShadowBranchNameForCommit("del1234", "e3b0c4") + result := filesOverlapWithContent(repo, shadowBranch, commit, []string{"to_delete.txt"}) + assert.True(t, result, "Deleted file should count as overlap (agent's deletion being committed)") +} + // TestFilesOverlapWithContent_NoShadowBranch tests fallback when shadow branch doesn't exist. func TestFilesOverlapWithContent_NoShadowBranch(t *testing.T) { t.Parallel() @@ -499,6 +544,58 @@ func TestStagedFilesOverlapWithContent_NoOverlap(t *testing.T) { assert.False(t, result, "Non-overlapping files should return false") } +// TestStagedFilesOverlapWithContent_DeletedFile tests that a deleted file +// (exists in HEAD but staged for deletion) DOES count as overlap. +// The agent's action of deleting the file is being committed, so the session +// context should be linked to this commit. +func TestStagedFilesOverlapWithContent_DeletedFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + // Create and commit a file that will be deleted + filePath := filepath.Join(dir, "to_delete.txt") + err = os.WriteFile(filePath, []byte("original content"), 0644) + require.NoError(t, err) + _, err = worktree.Add("to_delete.txt") + require.NoError(t, err) + _, err = worktree.Commit("Add to_delete.txt", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + // Create shadow branch (simulating agent work on the file) + createShadowBranchWithContent(t, repo, "mno7890", "e3b0c4", map[string][]byte{ + "to_delete.txt": []byte("agent modified content"), + }) + + // Stage the file for deletion (git rm) + _, err = worktree.Remove("to_delete.txt") + require.NoError(t, err) + + // Get shadow tree + shadowBranch := checkpoint.ShadowBranchNameForCommit("mno7890", "e3b0c4") + shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true) + require.NoError(t, err) + shadowCommit, err := repo.CommitObject(shadowRef.Hash()) + require.NoError(t, err) + shadowTree, err := shadowCommit.Tree() + require.NoError(t, err) + + // Deleted file SHOULD count as overlap - the agent's deletion is being committed + result := stagedFilesOverlapWithContent(repo, shadowTree, []string{"to_delete.txt"}, []string{"to_delete.txt"}) + assert.True(t, result, "Deleted file should count as overlap (agent's deletion being committed)") +} + // createShadowBranchWithContent creates a shadow branch with the given file contents. // This helper directly uses go-git APIs to avoid paths.RepoRoot() dependency. // diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index fc24205d7..1e6beb00c 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -146,7 +146,12 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI // committed in this specific commit. This ensures each checkpoint represents // exactly the files in that commit, not all files mentioned in the transcript. if len(committedFiles) > 0 { - if len(sessionData.FilesTouched) > 0 { + // Track if we had files before filtering to distinguish between: + // - Session had files but none were committed (don't fallback) + // - Session had no files to begin with (mid-session commit, fallback OK) + hadFilesBeforeFiltering := len(sessionData.FilesTouched) > 0 + + if hadFilesBeforeFiltering { // Filter to intersection of transcript-extracted files and committed files filtered := make([]string, 0, len(sessionData.FilesTouched)) for _, f := range sessionData.FilesTouched { @@ -157,10 +162,12 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI sessionData.FilesTouched = filtered } - // If extraction failed or returned empty, use committedFiles as fallback. - // This handles mid-session commits where transcript parsing may not find files - // but we know what was committed. - if len(sessionData.FilesTouched) == 0 { + // Only use committedFiles as fallback for genuine mid-session commits where + // no files were tracked yet (extraction returned empty). Do NOT fallback when + // the session had files that simply didn't overlap with the commit - that + // indicates an unrelated session that shouldn't have its files_touched + // overwritten with unrelated committed files. + if len(sessionData.FilesTouched) == 0 && !hadFilesBeforeFiltering { sessionData.FilesTouched = make([]string, 0, len(committedFiles)) for f := range committedFiles { sessionData.FilesTouched = append(sessionData.FilesTouched, f) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index e4e3353dc..9fd817efe 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -479,21 +479,14 @@ type postCommitActionHandler struct { shadowBranchesToDelete map[string]struct{} committedFileSet map[string]struct{} hasNew bool + filesTouchedBefore []string // Output: set by handler methods, read by caller after TransitionAndLog. condensed bool } func (h *postCommitActionHandler) HandleCondense(state *session.State) error { - // 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 AND have - // matching content — otherwise an unrelated commit (or a commit with - // completely replaced content) would incorrectly get this session's checkpoint. - shouldCondense := h.hasNew - if shouldCondense && !state.Phase.IsActive() { - shouldCondense = filesOverlapWithContent(h.repo, h.shadowBranchName, h.commit, state.FilesTouched) - } + shouldCondense := h.shouldCondenseWithOverlapCheck(state.Phase.IsActive()) logging.Debug(h.logCtx, "post-commit: HandleCondense decision", slog.String("session_id", state.SessionID), @@ -512,7 +505,7 @@ func (h *postCommitActionHandler) HandleCondense(state *session.State) error { } func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.State) error { - shouldCondense := len(state.FilesTouched) > 0 && h.hasNew + shouldCondense := len(state.FilesTouched) > 0 && h.shouldCondenseWithOverlapCheck(state.Phase.IsActive()) logging.Debug(h.logCtx, "post-commit: HandleCondenseIfFilesTouched decision", slog.String("session_id", state.SessionID), @@ -531,6 +524,47 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St return nil } +// shouldCondenseWithOverlapCheck returns true if the session should be condensed +// into this commit. Requires both that hasNew is true AND that the session's files +// overlap with the committed files with matching content. +// +// This prevents stale sessions (ACTIVE sessions where the agent was killed, or +// ENDED/IDLE sessions with carry-forward files) from being condensed into every +// unrelated commit. +// +// filesTouchedBefore is populated from: +// - state.FilesTouched for IDLE/ENDED sessions (set via SaveStep/SaveTaskStep -> mergeFilesTouched) +// - transcript extraction for ACTIVE sessions when FilesTouched is empty +// +// When filesTouchedBefore is empty: +// - For ACTIVE sessions: fail-open (trust hasNew) because the agent may be +// mid-turn before any files are saved to state. +// - For IDLE/ENDED sessions: return false because there are no files to +// overlap with the commit. +func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool) bool { + if !h.hasNew { + return false + } + if len(h.filesTouchedBefore) == 0 { + return isActive // ACTIVE: fail-open; IDLE/ENDED: no files = no overlap + } + // Only check files that were actually changed in this commit. + // Without this, files that exist in the tree but weren't changed + // would pass the "modified file" check in filesOverlapWithContent + // (because the file exists in the parent tree), causing stale + // sessions to be incorrectly condensed. + var committedTouchedFiles []string + for _, f := range h.filesTouchedBefore { + if _, ok := h.committedFileSet[f]; ok { + committedTouchedFiles = append(committedTouchedFiles, f) + } + } + if len(committedTouchedFiles) == 0 { + return false + } + return filesOverlapWithContent(h.repo, h.shadowBranchName, h.commit, committedTouchedFiles) +} + func (h *postCommitActionHandler) HandleDiscardIfNoFiles(state *session.State) error { if len(state.FilesTouched) == 0 { logging.Debug(h.logCtx, "post-commit: skipping empty ended session (no files to condense)", @@ -674,6 +708,7 @@ func (s *ManualCommitStrategy) PostCommit() error { shadowBranchesToDelete: shadowBranchesToDelete, committedFileSet: committedFileSet, hasNew: hasNew, + filesTouchedBefore: filesTouchedBefore, } if err := TransitionAndLog(state, session.EventGitCommit, transitionCtx, handler); err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 3f9c1d8ea..ed343c3f6 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -3352,3 +3352,233 @@ func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) { t.Error("Prompts should contain second prompt") } } + +// TestCondenseSession_FilesTouchedFallback_EmptyState verifies that when state.FilesTouched +// is empty (mid-session commit before SaveStep), the fallback to committedFiles works. +// This is the legitimate use case for the fallback. +func TestCondenseSession_FilesTouchedFallback_EmptyState(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + // Create initial commit + initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + AllowEmptyCommits: true, + }) + if err != nil { + t.Fatalf("failed to create initial commit: %v", err) + } + + // Create a file and commit it (simulating agent mid-turn commit) + agentFile := filepath.Join(dir, "agent.go") + if err := os.WriteFile(agentFile, []byte("package main\n"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if _, err := worktree.Add("agent.go"); err != nil { + t.Fatalf("failed to stage file: %v", err) + } + if _, err = worktree.Commit("Add agent.go", &git.CommitOptions{ + Author: &object.Signature{Name: "Agent", Email: "agent@test.com", When: time.Now()}, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + t.Chdir(dir) + + // Create live transcript (required when no shadow branch) + transcriptDir := filepath.Join(dir, ".claude", "projects", "test") + if err := os.MkdirAll(transcriptDir, 0o755); err != nil { + t.Fatalf("failed to create transcript dir: %v", err) + } + transcriptFile := filepath.Join(transcriptDir, "session.jsonl") + if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"create agent.go"}} +{"type":"assistant","message":{"content":"Done"}} +`), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Session state with EMPTY FilesTouched (mid-session commit scenario) + state := &SessionState{ + SessionID: "test-empty-files", + BaseCommit: initialHash.String(), + FilesTouched: []string{}, // Empty - no SaveStep called yet + TranscriptPath: transcriptFile, + AgentType: "Claude Code", + } + + s := &ManualCommitStrategy{} + checkpointID := id.MustCheckpointID("fa11bac00001") + + // Condense with committedFiles - should fallback since FilesTouched is empty + committedFiles := map[string]struct{}{"agent.go": {}} + result, err := s.CondenseSession(repo, checkpointID, state, committedFiles) + if err != nil { + t.Fatalf("CondenseSession() error = %v", err) + } + + // Read metadata and verify files_touched contains the committed file (fallback worked) + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("failed to get sessions branch: %v", err) + } + sessionsCommit, err := repo.CommitObject(sessionsRef.Hash()) + if err != nil { + t.Fatalf("failed to get sessions commit: %v", err) + } + tree, err := sessionsCommit.Tree() + if err != nil { + t.Fatalf("failed to get tree: %v", err) + } + + metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName + metadataFile, err := tree.File(metadataPath) + if err != nil { + t.Fatalf("failed to find metadata: %v", err) + } + content, err := metadataFile.Contents() + if err != nil { + t.Fatalf("failed to read metadata: %v", err) + } + + var metadata struct { + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal([]byte(content), &metadata); err != nil { + t.Fatalf("failed to parse metadata: %v", err) + } + + // Verify fallback worked - files_touched should contain agent.go + if len(metadata.FilesTouched) != 1 || metadata.FilesTouched[0] != "agent.go" { + t.Errorf("files_touched = %v, want [agent.go] (fallback should apply when FilesTouched is empty)", + metadata.FilesTouched) + } + + t.Logf("Fallback worked: files_touched = %v, result = %+v", metadata.FilesTouched, result) +} + +// TestCondenseSession_FilesTouchedNoFallback_NoOverlap verifies that when state.FilesTouched +// has files but none overlap with committedFiles, we do NOT fallback to committedFiles. +// This prevents the bug where unrelated sessions get incorrect files_touched. +func TestCondenseSession_FilesTouchedNoFallback_NoOverlap(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + // Create initial commit + initialHash, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + AllowEmptyCommits: true, + }) + if err != nil { + t.Fatalf("failed to create initial commit: %v", err) + } + + // Create files for both the session's work and the committed file + sessionFile := filepath.Join(dir, "session_file.go") + if err := os.WriteFile(sessionFile, []byte("package session\n"), 0o644); err != nil { + t.Fatalf("failed to write session file: %v", err) + } + committedFile := filepath.Join(dir, "other_file.go") + if err := os.WriteFile(committedFile, []byte("package other\n"), 0o644); err != nil { + t.Fatalf("failed to write committed file: %v", err) + } + + // Only commit the "other" file (not the session's file) + if _, err := worktree.Add("other_file.go"); err != nil { + t.Fatalf("failed to stage file: %v", err) + } + if _, err = worktree.Commit("Add other_file.go", &git.CommitOptions{ + Author: &object.Signature{Name: "Human", Email: "human@test.com", When: time.Now()}, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + t.Chdir(dir) + + // Create live transcript + transcriptDir := filepath.Join(dir, ".claude", "projects", "test") + if err := os.MkdirAll(transcriptDir, 0o755); err != nil { + t.Fatalf("failed to create transcript dir: %v", err) + } + transcriptFile := filepath.Join(transcriptDir, "session.jsonl") + if err := os.WriteFile(transcriptFile, []byte(`{"type":"human","message":{"content":"work on session_file.go"}} +{"type":"assistant","message":{"content":"Done"}} +`), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Session state with FilesTouched that does NOT overlap with committedFiles + state := &SessionState{ + SessionID: "test-no-overlap", + BaseCommit: initialHash.String(), + FilesTouched: []string{"session_file.go"}, // Does NOT overlap with other_file.go + TranscriptPath: transcriptFile, + AgentType: "Claude Code", + } + + s := &ManualCommitStrategy{} + checkpointID := id.MustCheckpointID("00001a000001") + + // Condense with committedFiles that don't overlap + committedFiles := map[string]struct{}{"other_file.go": {}} + result, err := s.CondenseSession(repo, checkpointID, state, committedFiles) + if err != nil { + t.Fatalf("CondenseSession() error = %v", err) + } + + // Read metadata and verify files_touched is EMPTY (no fallback applied) + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("failed to get sessions branch: %v", err) + } + sessionsCommit, err := repo.CommitObject(sessionsRef.Hash()) + if err != nil { + t.Fatalf("failed to get sessions commit: %v", err) + } + tree, err := sessionsCommit.Tree() + if err != nil { + t.Fatalf("failed to get tree: %v", err) + } + + metadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName + metadataFile, err := tree.File(metadataPath) + if err != nil { + t.Fatalf("failed to find metadata: %v", err) + } + content, err := metadataFile.Contents() + if err != nil { + t.Fatalf("failed to read metadata: %v", err) + } + + var metadata struct { + FilesTouched []string `json:"files_touched"` + } + if err := json.Unmarshal([]byte(content), &metadata); err != nil { + t.Fatalf("failed to parse metadata: %v", err) + } + + // Verify NO fallback - files_touched should be EMPTY, NOT contain other_file.go + // This is the key fix: session had files (session_file.go) but none overlapped, + // so we should NOT fallback to committedFiles (other_file.go) + if len(metadata.FilesTouched) != 0 { + t.Errorf("files_touched = %v, want [] (should NOT fallback when session had files but no overlap)", + metadata.FilesTouched) + } + + t.Logf("No fallback applied: files_touched = %v (correctly empty), result = %+v", metadata.FilesTouched, result) +} diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index bd8cc4838..a02fdc32c 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -20,6 +20,8 @@ import ( "github.com/stretchr/testify/require" ) +const testNewActiveSessionID = "new-active-session" + // 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. @@ -1191,9 +1193,24 @@ func TestHandleTurnEnd_PartialFailure(t *testing.T) { commitWithCheckpointTrailer(t, repo, dir, "a1b2c3d4e5f6") require.NoError(t, s.PostCommit()) - // Write new content and create a second real checkpoint + // Write new content and create a second checkpoint on the shadow branch. + // Use SaveStep directly (instead of setupSessionWithCheckpoint) so that + // second.txt is included in FilesTouched — the overlap check needs it. require.NoError(t, os.WriteFile(filepath.Join(dir, "second.txt"), []byte("second file"), 0o644)) - setupSessionWithCheckpoint(t, s, repo, dir, sessionID) // refresh shadow branch + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + err = s.SaveStep(StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{"test.txt"}, + NewFiles: []string{"second.txt"}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint 2", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err, "SaveStep should succeed for second checkpoint") state, err = s.loadSessionState(sessionID) require.NoError(t, err) state.Phase = session.PhaseActive @@ -1370,7 +1387,7 @@ func TestPostCommit_OldIdleSession_BaseCommitNotUpdated(t *testing.T) { require.NoError(t, err) // --- Create a NEW ACTIVE session at the new HEAD --- - newSessionID := "new-active-session" + newSessionID := testNewActiveSessionID setupSessionWithCheckpoint(t, s, repo, dir, newSessionID) newState, err := s.loadSessionState(newSessionID) @@ -1453,7 +1470,7 @@ func TestPostCommit_OldEndedSession_BaseCommitNotUpdated(t *testing.T) { require.NoError(t, err) // --- Create a NEW ACTIVE session at the new HEAD --- - newSessionID := "new-active-session" + newSessionID := testNewActiveSessionID setupSessionWithCheckpoint(t, s, repo, dir, newSessionID) newState, err := s.loadSessionState(newSessionID) @@ -1487,3 +1504,332 @@ func TestPostCommit_OldEndedSession_BaseCommitNotUpdated(t *testing.T) { assert.Equal(t, newHead, newState.BaseCommit, "NEW ACTIVE session's BaseCommit should be updated after condensation") } + +// TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit verifies +// that an ENDED session with carry-forward files is NOT condensed into a commit +// that doesn't touch any of those files. +// +// This is the primary bug scenario: ENDED sessions go through HandleCondenseIfFilesTouched, +// which previously only checked len(FilesTouched) > 0 && hasNew — no overlap check. +// Carry-forward would set FilesTouched with remaining uncommitted files, and +// sessionHasNewContent returned true because the shadow branch had content. This +// caused ENDED sessions to be re-condensed into every subsequent commit indefinitely. +func TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + + // --- Create an ENDED session with carry-forward files --- + endedSessionID := "ended-carry-forward" + setupSessionWithCheckpoint(t, s, repo, dir, endedSessionID) + + endedState, err := s.loadSessionState(endedSessionID) + require.NoError(t, err) + now := time.Now() + endedState.Phase = session.PhaseEnded + endedState.EndedAt = &now + // Simulate carry-forward: session touched test.txt but it wasn't fully committed yet. + // CheckpointTranscriptStart=0 so sessionHasNewContent returns true (transcript grew). + endedState.FilesTouched = []string{"test.txt"} + endedState.CheckpointTranscriptStart = 0 + require.NoError(t, s.saveSessionState(endedState)) + + endedOriginalBaseCommit := endedState.BaseCommit + endedOriginalStepCount := endedState.StepCount + + // Move HEAD forward with an unrelated commit (no trailer) + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated work"), 0o644)) + _, err = wt.Add("unrelated.txt") + require.NoError(t, err) + _, err = wt.Commit("unrelated commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // --- Create a NEW ACTIVE session at the new HEAD --- + newSessionID := testNewActiveSessionID + require.NoError(t, os.WriteFile(filepath.Join(dir, "new-feature.txt"), []byte("new feature content"), 0o644)) + + metadataDir := ".entire/metadata/" + newSessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + transcript := `{"type":"human","message":{"content":"add new feature"}} +{"type":"assistant","message":{"content":"adding new feature"}} +` + require.NoError(t, os.WriteFile( + filepath.Join(metadataDirAbs, paths.TranscriptFileName), + []byte(transcript), 0o644)) + + err = s.SaveStep(StepContext{ + SessionID: newSessionID, + ModifiedFiles: []string{}, + NewFiles: []string{"new-feature.txt"}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint: new feature", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + newState, err := s.loadSessionState(newSessionID) + require.NoError(t, err) + newState.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(newState)) + + // --- Commit ONLY new-feature.txt (not test.txt) with checkpoint trailer --- + wt, err = repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("new-feature.txt") + require.NoError(t, err) + + cpID := "ae1ae2ae3ae4" + commitMsg := "add new feature\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n" + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + head, err := repo.Head() + require.NoError(t, err) + newHead := head.Hash().String() + + // Run PostCommit + err = s.PostCommit() + require.NoError(t, err) + + // --- Verify: ENDED session was NOT condensed --- + endedState, err = s.loadSessionState(endedSessionID) + require.NoError(t, err) + + // StepCount should be unchanged (not reset by condensation) + assert.Equal(t, endedOriginalStepCount, endedState.StepCount, + "ENDED session StepCount should NOT be reset (no condensation)") + + // BaseCommit should NOT be updated for ENDED sessions (PR #359) + assert.Equal(t, endedOriginalBaseCommit, endedState.BaseCommit, + "ENDED session BaseCommit should NOT be updated") + + // FilesTouched should still have the carry-forward files (not cleared by condensation) + assert.Equal(t, []string{"test.txt"}, endedState.FilesTouched, + "ENDED session FilesTouched should be preserved (carry-forward files not consumed)") + + // Phase stays ENDED + assert.Equal(t, session.PhaseEnded, endedState.Phase, + "ENDED session should remain ENDED") + + // --- Verify: new ACTIVE session WAS condensed --- + newState, err = s.loadSessionState(newSessionID) + require.NoError(t, err) + assert.Equal(t, 0, newState.StepCount, + "New ACTIVE session StepCount should be reset by condensation") + assert.Equal(t, newHead, newState.BaseCommit, + "New ACTIVE session BaseCommit should be updated after condensation") +} + +// TestPostCommit_StaleActiveSession_NotCondensed verifies that a stale ACTIVE +// session (agent killed without Stop hook) is NOT condensed into an unrelated +// commit from a different session. +// +// Root cause: when an agent is killed without the Stop hook firing, its session +// remains in ACTIVE phase permanently. Previously, PostCommit unconditionally +// set hasNew=true for ACTIVE sessions and skipped the filesOverlapWithContent +// check, so stale ACTIVE sessions got condensed into every commit. +// +// The fix applies the overlap check to ALL sessions (including ACTIVE) using +// filesTouchedBefore, so stale sessions with unrelated files are filtered out. +func TestPostCommit_StaleActiveSession_NotCondensed(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + + // --- Create a stale ACTIVE session from an old commit --- + // This simulates an agent that was killed without the Stop hook firing. + staleSessionID := "stale-active-session" + setupSessionWithCheckpoint(t, s, repo, dir, staleSessionID) + + staleState, err := s.loadSessionState(staleSessionID) + require.NoError(t, err) + staleState.Phase = session.PhaseActive + // The stale session touched "test.txt" (set by setupSessionWithCheckpoint) + // but the new commit will modify a different file. + staleState.FilesTouched = []string{"test.txt"} + require.NoError(t, s.saveSessionState(staleState)) + + staleOriginalBaseCommit := staleState.BaseCommit + staleOriginalStepCount := staleState.StepCount + + // Move HEAD forward with an unrelated commit (no trailer) + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("unrelated work"), 0o644)) + _, err = wt.Add("unrelated.txt") + require.NoError(t, err) + _, err = wt.Commit("unrelated commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // --- Create a NEW ACTIVE session at the new HEAD --- + newSessionID := testNewActiveSessionID + + // Create a new file for the new session (different from stale session's test.txt) + require.NoError(t, os.WriteFile(filepath.Join(dir, "new-feature.txt"), []byte("new feature content"), 0o644)) + + metadataDir := ".entire/metadata/" + newSessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + transcript := `{"type":"human","message":{"content":"add new feature"}} +{"type":"assistant","message":{"content":"adding new feature"}} +` + require.NoError(t, os.WriteFile( + filepath.Join(metadataDirAbs, paths.TranscriptFileName), + []byte(transcript), 0o644)) + + err = s.SaveStep(StepContext{ + SessionID: newSessionID, + ModifiedFiles: []string{}, + NewFiles: []string{"new-feature.txt"}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint: new feature", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + newState, err := s.loadSessionState(newSessionID) + require.NoError(t, err) + newState.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(newState)) + + // --- Commit ONLY new-feature.txt (not test.txt) with checkpoint trailer --- + wt, err = repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("new-feature.txt") + require.NoError(t, err) + + cpID := "de1de2de3de4" + commitMsg := "add new feature\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n" + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + head, err := repo.Head() + require.NoError(t, err) + newHead := head.Hash().String() + + // Run PostCommit + err = s.PostCommit() + require.NoError(t, err) + + // --- Verify: stale ACTIVE session was NOT condensed --- + staleState, err = s.loadSessionState(staleSessionID) + require.NoError(t, err) + + // StepCount should be unchanged (not reset by condensation) + assert.Equal(t, staleOriginalStepCount, staleState.StepCount, + "Stale ACTIVE session StepCount should NOT be reset (no condensation)") + + // BaseCommit IS updated for ACTIVE sessions (updateBaseCommitIfChanged) + assert.Equal(t, newHead, staleState.BaseCommit, + "Stale ACTIVE session BaseCommit should be updated (ACTIVE sessions always get BaseCommit updated)") + assert.NotEqual(t, staleOriginalBaseCommit, staleState.BaseCommit, + "Stale ACTIVE session BaseCommit should have changed") + + // Phase stays ACTIVE + assert.Equal(t, session.PhaseActive, staleState.Phase, + "Stale ACTIVE session should remain ACTIVE") + + // --- Verify: new ACTIVE session WAS condensed --- + newState, err = s.loadSessionState(newSessionID) + require.NoError(t, err) + + // StepCount reset to 0 by condensation + assert.Equal(t, 0, newState.StepCount, + "New ACTIVE session StepCount should be reset by condensation") + + // BaseCommit updated to new HEAD + assert.Equal(t, newHead, newState.BaseCommit, + "New ACTIVE session BaseCommit should be updated after condensation") + + // Verify entire/checkpoints/v1 exists (new session was condensed) + _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err, + "entire/checkpoints/v1 should exist (new session was condensed)") +} + +// TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed verifies that an IDLE +// session with hasNew=true but empty FilesTouched is NOT condensed into a commit. +// +// This can happen for conversation-only sessions where the transcript grew but no +// files were modified. Previously, filesOverlapWithContent was called with an empty +// list and returned false. The shouldCondenseWithOverlapCheck method must also +// return false when filesTouchedBefore is empty. +func TestPostCommit_IdleSessionEmptyFilesTouched_NotCondensed(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + + // --- Create an IDLE session with a checkpoint but no files touched --- + idleSessionID := "idle-no-files-session" + setupSessionWithCheckpoint(t, s, repo, dir, idleSessionID) + + idleState, err := s.loadSessionState(idleSessionID) + require.NoError(t, err) + idleState.Phase = session.PhaseIdle + // Clear FilesTouched to simulate a conversation-only session + idleState.FilesTouched = nil + // CheckpointTranscriptStart=0 so sessionHasNewContent returns true + idleState.CheckpointTranscriptStart = 0 + require.NoError(t, s.saveSessionState(idleState)) + + idleOriginalStepCount := idleState.StepCount + + // --- Make a commit with an unrelated file --- + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "other-work.txt"), []byte("other work"), 0o644)) + _, err = wt.Add("other-work.txt") + require.NoError(t, err) + + cpID := "f1f2f3f4f5f6" + commitMsg := "other work\n\n" + trailers.CheckpointTrailerKey + ": " + cpID + "\n" + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + // Run PostCommit + err = s.PostCommit() + require.NoError(t, err) + + // --- Verify: IDLE session with no files was NOT condensed --- + idleState, err = s.loadSessionState(idleSessionID) + require.NoError(t, err) + + assert.Equal(t, idleOriginalStepCount, idleState.StepCount, + "IDLE session with empty FilesTouched should NOT be condensed") + assert.Equal(t, session.PhaseIdle, idleState.Phase, + "IDLE session should remain IDLE") + // BaseCommit is NOT updated for non-ACTIVE sessions (updateBaseCommitIfChanged skips them) +}