diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index f6eacee1a..0607b542f 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -1071,6 +1071,114 @@ func TestResume_LocalLogNoTimestamp(t *testing.T) { } } +// TestResume_SquashMergeMultipleCheckpoints tests resume when a squash merge commit +// contains multiple Entire-Checkpoint trailers from different sessions/commits. +// This simulates the GitHub squash merge workflow where: +// 1. Developer creates feature branch with multiple commits, each with its own checkpoint +// 2. PR is squash-merged to main, combining all commit messages (and their checkpoint trailers) +// 3. Feature branch is deleted +// 4. Running "entire resume main" should discover and restore all sessions from the squash commit +func TestResume_SquashMergeMultipleCheckpoints(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // === Session 1: First piece of work on feature branch === + session1 := env.NewSession() + if err := env.SimulateUserPromptSubmit(session1.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit session1 failed: %v", err) + } + + content1 := "puts 'hello world'" + env.WriteFile("hello.rb", content1) + + session1.CreateTranscript( + "Create hello script", + []FileChange{{Path: "hello.rb", Content: content1}}, + ) + if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { + t.Fatalf("SimulateStop session1 failed: %v", err) + } + + // Commit session 1 (triggers condensation → checkpoint 1 on entire/checkpoints/v1) + env.GitCommitWithShadowHooks("Create hello script", "hello.rb") + checkpointID1 := env.GetLatestCheckpointID() + t.Logf("Session 1 checkpoint: %s", checkpointID1) + + // === Session 2: Second piece of work on feature branch === + session2 := env.NewSession() + if err := env.SimulateUserPromptSubmit(session2.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit session2 failed: %v", err) + } + + content2 := "puts 'goodbye world'" + env.WriteFile("goodbye.rb", content2) + + session2.CreateTranscript( + "Create goodbye script", + []FileChange{{Path: "goodbye.rb", Content: content2}}, + ) + if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil { + t.Fatalf("SimulateStop session2 failed: %v", err) + } + + // Commit session 2 (triggers condensation → checkpoint 2 on entire/checkpoints/v1) + env.GitCommitWithShadowHooks("Create goodbye script", "goodbye.rb") + checkpointID2 := env.GetLatestCheckpointID() + t.Logf("Session 2 checkpoint: %s", checkpointID2) + + // Verify we got two different checkpoint IDs + if checkpointID1 == checkpointID2 { + t.Fatalf("expected different checkpoint IDs, got same: %s", checkpointID1) + } + + // === Simulate squash merge: switch to master, create squash commit === + env.GitCheckoutBranch(masterBranch) + + // Write the combined file changes (as if squash merged) + env.WriteFile("hello.rb", content1) + env.WriteFile("goodbye.rb", content2) + env.GitAdd("hello.rb") + env.GitAdd("goodbye.rb") + + // Create squash merge commit with both checkpoint trailers in the message + // This mimics GitHub's squash merge format: PR title + individual commit messages + env.GitCommitWithMultipleCheckpoints( + "Feature branch (#1)\n\n* Create hello script\n\n* Create goodbye script", + []string{checkpointID1, checkpointID2}, + ) + + // Remove local session logs (simulating a fresh machine or deleted local state) + if err := os.RemoveAll(env.ClaudeProjectDir); err != nil { + t.Fatalf("failed to remove Claude project dir: %v", err) + } + + // === Run resume on master === + output, err := env.RunResume(masterBranch) + if err != nil { + t.Fatalf("resume failed: %v\nOutput: %s", err, output) + } + + t.Logf("Resume output:\n%s", output) + + // Should restore both sessions (multi-checkpoint path) + if !strings.Contains(output, "Restored 2 sessions") { + t.Errorf("expected 'Restored 2 sessions' in output, got: %s", output) + } + + // Should contain resume commands for both sessions + if !strings.Contains(output, session1.ID) { + t.Errorf("expected session1 ID %s in output, got: %s", session1.ID, output) + } + if !strings.Contains(output, session2.ID) { + t.Errorf("expected session2 ID %s in output, got: %s", session2.ID, output) + } + + // Should contain claude -r commands + if !strings.Contains(output, "claude -r") { + t.Errorf("expected 'claude -r' in output, got: %s", output) + } +} + // TestResume_RelocatedRepo tests that resume works when a repository is moved // to a different directory after checkpoint creation. This validates that resume // reads checkpoint data from the git metadata branch (which travels with the repo) diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index add1e3c1b..400fcab63 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -499,6 +499,42 @@ func (env *TestEnv) GitCommitWithMultipleSessions(message string, sessionIDs []s } } +// GitCommitWithMultipleCheckpoints creates a commit with multiple Entire-Checkpoint trailers. +// This simulates a GitHub squash merge commit where multiple individual commits with +// checkpoint trailers are combined into a single commit message. +func (env *TestEnv) GitCommitWithMultipleCheckpoints(message string, checkpointIDs []string) { + env.T.Helper() + + // Format message with multiple checkpoint trailers (simulating squash merge format) + var sb strings.Builder + sb.WriteString(message) + sb.WriteString("\n\n") + for _, cpID := range checkpointIDs { + sb.WriteString("Entire-Checkpoint: " + cpID + "\n") + } + + 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(sb.String(), &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }, + }) + if err != nil { + env.T.Fatalf("failed to commit: %v", err) + } +} + // GetHeadHash returns the current HEAD commit hash. func (env *TestEnv) GetHeadHash() string { env.T.Helper() diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 31d117034..ebfc65908 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -127,11 +128,11 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) } // Find a commit with an Entire-Checkpoint trailer, looking at branch-only commits - result, err := findBranchCheckpoint(repo, branchName) + result, err := findBranchCheckpoints(repo, branchName) if err != nil { return err } - if result.checkpointID.IsEmpty() { + if len(result.checkpointIDs) == 0 { fmt.Fprintf(os.Stderr, "No Entire checkpoint found on branch '%s'\n", branchName) return nil } @@ -153,7 +154,13 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) } } - checkpointID := result.checkpointID + // Multiple checkpoints (squash merge): restore all sessions + if len(result.checkpointIDs) > 1 { + return resumeMultipleCheckpoints(ctx, repo, result.checkpointIDs, force) + } + + // Single checkpoint: existing behavior (unchanged) + checkpointID := result.checkpointIDs[0] // Get metadata branch tree for lookups metadataTree, err := strategy.GetMetadataBranchTree(repo) @@ -172,20 +179,144 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) return resumeSession(ctx, metadata.SessionID, checkpointID, force) } -// branchCheckpointResult contains the result of searching for a checkpoint on a branch. -type branchCheckpointResult struct { - checkpointID id.CheckpointID +// resumeMultipleCheckpoints restores sessions from multiple checkpoint IDs. +// This handles squash merge commits where multiple Entire-Checkpoint trailers +// are present in a single commit message. Each checkpoint is looked up independently +// and missing metadata is skipped (best-effort). +func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkpointIDs []id.CheckpointID, force bool) error { + logCtx := logging.WithComponent(ctx, "resume") + + // Get metadata branch tree (try local first, then remote) + metadataTree, err := strategy.GetMetadataBranchTree(repo) + if err != nil { + // Try fetching from remote + logging.Debug(logCtx, "metadata branch not available locally, trying remote") + if fetchErr := FetchMetadataBranch(ctx); fetchErr != nil { + // If fetch also fails, try remote tree directly + remoteTree, remoteErr := strategy.GetRemoteMetadataBranchTree(repo) + if remoteErr != nil { + fmt.Fprintf(os.Stderr, "Checkpoint metadata not available locally or on remote\n") + return nil //nolint:nilerr // Informational message, not a fatal error + } + metadataTree = remoteTree + } else { + metadataTree, err = strategy.GetMetadataBranchTree(repo) + if err != nil { + fmt.Fprintf(os.Stderr, "Checkpoint metadata not available after fetch\n") + return nil //nolint:nilerr // Informational message, not a fatal error + } + } + } + + // Read metadata for all checkpoints and sort by CreatedAt ascending + // (oldest first → newest writes last and wins on disk) + checkpoints := collectCheckpointsByAge(metadataTree, checkpointIDs) + + // Iterate sorted checkpoints and restore + strat := GetStrategy(ctx) + var allSessions []strategy.RestoredSession + + for _, cp := range checkpoints { + point := strategy.RewindPoint{ + IsLogsOnly: true, + CheckpointID: cp.CheckpointID, + Agent: cp.Agent, + } + + sessions, restoreErr := strat.RestoreLogsOnly(ctx, point, force) + if restoreErr != nil { + logging.Debug(logCtx, "skipping checkpoint: restore failed", + slog.String("checkpoint_id", cp.CheckpointID.String()), + slog.String("error", restoreErr.Error()), + ) + continue + } + if len(sessions) == 0 { + logging.Debug(logCtx, "skipping checkpoint: no sessions restored", + slog.String("checkpoint_id", cp.CheckpointID.String()), + ) + continue + } + + allSessions = deduplicateSessions(allSessions, sessions) + } + + if len(allSessions) == 0 { + fmt.Fprintf(os.Stderr, "No session metadata found for checkpoints in this commit\n") + return nil + } + + logging.Debug(logCtx, "resume multiple checkpoints completed", + slog.Int("checkpoint_count", len(checkpointIDs)), + slog.Int("session_count", len(allSessions)), + ) + + return displayRestoredSessions(allSessions) +} + +// collectCheckpointsByAge reads metadata for each checkpoint ID from the tree, +// skips any that can't be read, and returns them sorted by CreatedAt ascending. +// Sorting ensures the newest checkpoint is restored last and wins on disk, +// regardless of trailer order in the commit message. +func collectCheckpointsByAge(tree *object.Tree, checkpointIDs []id.CheckpointID) []*strategy.CheckpointInfo { + var checkpoints []*strategy.CheckpointInfo + for _, cpID := range checkpointIDs { + metadata, err := strategy.ReadCheckpointMetadata(tree, cpID.Path()) + if err != nil { + continue + } + checkpoints = append(checkpoints, metadata) + } + sort.Slice(checkpoints, func(i, j int) bool { + return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) + }) + return checkpoints +} + +// deduplicateSessions merges new sessions into existing, keeping the one with the latest +// CreatedAt when a SessionID appears more than once. This handles squash merges where the +// same session may be referenced by multiple checkpoints. +func deduplicateSessions(existing, incoming []strategy.RestoredSession) []strategy.RestoredSession { + type entry struct { + index int + createdAt time.Time + } + + seen := make(map[string]entry, len(existing)) + for i, s := range existing { + seen[s.SessionID] = entry{index: i, createdAt: s.CreatedAt} + } + + for _, sess := range incoming { + if prev, exists := seen[sess.SessionID]; exists { + // Keep the one with the later CreatedAt (more complete transcript) + if sess.CreatedAt.After(prev.createdAt) { + existing[prev.index] = sess + seen[sess.SessionID] = entry{index: prev.index, createdAt: sess.CreatedAt} + } + } else { + seen[sess.SessionID] = entry{index: len(existing), createdAt: sess.CreatedAt} + existing = append(existing, sess) + } + } + + return existing +} + +// branchCheckpointsResult contains the result of searching for checkpoints on a branch. +type branchCheckpointsResult struct { + checkpointIDs []id.CheckpointID commitHash string commitMessage string newerCommitsExist bool // true if there are branch-only commits (not merge commits) without checkpoints newerCommitCount int // count of branch-only commits without checkpoints } -// findBranchCheckpoint finds the most recent commit with an Entire-Checkpoint trailer +// findBranchCheckpoints finds the most recent commit with an Entire-Checkpoint trailer // among commits that are unique to this branch (not reachable from the default branch). // This handles the case where main has been merged into the feature branch. -func findBranchCheckpoint(repo *git.Repository, branchName string) (*branchCheckpointResult, error) { - result := &branchCheckpointResult{} +func findBranchCheckpoints(repo *git.Repository, branchName string) (*branchCheckpointsResult, error) { + result := &branchCheckpointsResult{} // Get HEAD commit head, err := repo.Head() @@ -199,8 +330,8 @@ func findBranchCheckpoint(repo *git.Repository, branchName string) (*branchCheck } // First, check if HEAD itself has a checkpoint (most common case) - if cpID, found := trailers.ParseCheckpoint(headCommit.Message); found { - result.checkpointID = cpID + if cpIDs := trailers.ParseAllCheckpoints(headCommit.Message); len(cpIDs) > 0 { + result.checkpointIDs = cpIDs result.commitHash = head.Hash().String() result.commitMessage = headCommit.Message result.newerCommitsExist = false @@ -254,8 +385,8 @@ func findBranchCheckpoint(repo *git.Repository, branchName string) (*branchCheck // Returns the first checkpoint found and info about commits between HEAD and the checkpoint. // It distinguishes between merge commits (bringing in other branches) and regular commits // (actual branch work) to avoid false warnings after merging main. -func findCheckpointInHistory(start *object.Commit, stopAt *plumbing.Hash) *branchCheckpointResult { - result := &branchCheckpointResult{} +func findCheckpointInHistory(start *object.Commit, stopAt *plumbing.Hash) *branchCheckpointsResult { + result := &branchCheckpointsResult{} branchWorkCommits := 0 // Regular commits without checkpoints (actual work) const maxCommits = 100 // Limit search depth totalChecked := 0 @@ -268,8 +399,8 @@ func findCheckpointInHistory(start *object.Commit, stopAt *plumbing.Hash) *branc } // Check for checkpoint trailer - if cpID, found := trailers.ParseCheckpoint(current.Message); found { - result.checkpointID = cpID + if cpIDs := trailers.ParseAllCheckpoints(current.Message); len(cpIDs) > 0 { + result.checkpointIDs = cpIDs result.commitHash = current.Hash.String() result.commitMessage = current.Message // Only warn about branch work commits, not merge commits @@ -420,24 +551,28 @@ func resumeSession(ctx context.Context, sessionID string, checkpointID id.Checkp return resumeSingleSession(ctx, ag, sessionID, checkpointID, repoRoot, force) } - // Sort sessions by CreatedAt so the most recent is last (for display). - // This fixes ordering when subdirectory index doesn't reflect activity order. - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) - }) - logging.Debug(logCtx, "resume session completed", slog.String("checkpoint_id", checkpointID.String()), slog.Int("session_count", len(sessions)), ) - // Print per-session resume commands using returned sessions + return displayRestoredSessions(sessions) +} + +// displayRestoredSessions sorts sessions by CreatedAt and prints resume commands. +// Used by both resumeSession (single checkpoint) and resumeMultipleCheckpoints (squash merge). +func displayRestoredSessions(sessions []strategy.RestoredSession) error { + sort.SliceStable(sessions, func(i, j int) bool { + return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) + }) + if len(sessions) > 1 { fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(sessions)) } else if len(sessions) == 1 { fmt.Fprintf(os.Stderr, "Session: %s\n", sessions[0].SessionID) fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") } + for i, sess := range sessions { sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent) if err != nil { @@ -445,26 +580,16 @@ func resumeSession(ctx context.Context, sessionID string, checkpointID id.Checkp } cmd := sessionAgent.FormatResumeCommand(sess.SessionID) - if len(sessions) > 1 { - if i == len(sessions)-1 { - if sess.Prompt != "" { - fmt.Fprintf(os.Stderr, " %s # %s (most recent)\n", cmd, sess.Prompt) - } else { - fmt.Fprintf(os.Stderr, " %s # (most recent)\n", cmd) - } - } else { - if sess.Prompt != "" { - fmt.Fprintf(os.Stderr, " %s # %s\n", cmd, sess.Prompt) - } else { - fmt.Fprintf(os.Stderr, " %s\n", cmd) - } - } - } else { - if sess.Prompt != "" { - fmt.Fprintf(os.Stderr, " %s # %s\n", cmd, sess.Prompt) - } else { - fmt.Fprintf(os.Stderr, " %s\n", cmd) - } + isLast := i == len(sessions)-1 + switch { + case len(sessions) > 1 && isLast && sess.Prompt != "": + fmt.Fprintf(os.Stderr, " %s # %s (most recent)\n", cmd, sess.Prompt) + case len(sessions) > 1 && isLast: + fmt.Fprintf(os.Stderr, " %s # (most recent)\n", cmd) + case sess.Prompt != "": + fmt.Fprintf(os.Stderr, " %s # %s\n", cmd, sess.Prompt) + default: + fmt.Fprintf(os.Stderr, " %s\n", cmd) } } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index c992db4a0..826117bb1 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -267,12 +268,17 @@ func TestRunResume_UncommittedChanges(t *testing.T) { } } -// createCheckpointOnMetadataBranch creates a checkpoint on the entire/checkpoints/v1 branch. -// Returns the checkpoint ID. +// createCheckpointOnMetadataBranch creates a checkpoint on the entire/checkpoints/v1 branch +// with a default checkpoint ID ("abc123def456") and default timestamp. func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessionID string) id.CheckpointID { t.Helper() + return createCheckpointOnMetadataBranchFull(t, repo, sessionID, id.MustCheckpointID("abc123def456"), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) +} - checkpointID := id.MustCheckpointID("abc123def456") // Fixed ID for testing +// createCheckpointOnMetadataBranchFull creates a checkpoint on the entire/checkpoints/v1 branch +// with a caller-specified checkpoint ID and timestamp. +func createCheckpointOnMetadataBranchFull(t *testing.T, repo *git.Repository, sessionID string, checkpointID id.CheckpointID, createdAt time.Time) id.CheckpointID { + t.Helper() // Get existing metadata branch or create it if err := strategy.EnsureMetadataBranch(repo); err != nil { @@ -294,8 +300,8 @@ func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessio metadataJSON := fmt.Sprintf(`{ "checkpoint_id": %q, "session_id": %q, - "created_at": "2025-01-01T00:00:00Z" -}`, checkpointID.String(), sessionID) + "created_at": %q +}`, checkpointID.String(), sessionID, createdAt.Format(time.RFC3339)) // Create blob for metadata blob := repo.Storer.NewEncodedObject() @@ -432,6 +438,270 @@ func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessio return checkpointID } +func TestDeduplicateSessions(t *testing.T) { + t.Parallel() + + t0 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t1 := t0.Add(1 * time.Hour) + t2 := t0.Add(2 * time.Hour) + + t.Run("no duplicates keeps all", func(t *testing.T) { + t.Parallel() + existing := []strategy.RestoredSession{ + {SessionID: "s1", CreatedAt: t0}, + } + incoming := []strategy.RestoredSession{ + {SessionID: "s2", CreatedAt: t1}, + } + got := deduplicateSessions(existing, incoming) + if len(got) != 2 { + t.Fatalf("got %d sessions, want 2", len(got)) + } + if got[0].SessionID != "s1" || got[1].SessionID != "s2" { + t.Errorf("got [%s, %s], want [s1, s2]", got[0].SessionID, got[1].SessionID) + } + }) + + t.Run("duplicate keeps newer", func(t *testing.T) { + t.Parallel() + existing := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "old", CreatedAt: t0}, + } + incoming := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "new", CreatedAt: t1}, + } + got := deduplicateSessions(existing, incoming) + if len(got) != 1 { + t.Fatalf("got %d sessions, want 1", len(got)) + } + if got[0].Prompt != "new" { + t.Errorf("got prompt %q, want %q", got[0].Prompt, "new") + } + }) + + t.Run("duplicate keeps existing when newer", func(t *testing.T) { + t.Parallel() + existing := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "newer", CreatedAt: t1}, + } + incoming := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "older", CreatedAt: t0}, + } + got := deduplicateSessions(existing, incoming) + if len(got) != 1 { + t.Fatalf("got %d sessions, want 1", len(got)) + } + if got[0].Prompt != "newer" { + t.Errorf("got prompt %q, want %q", got[0].Prompt, "newer") + } + }) + + t.Run("three occurrences keeps latest", func(t *testing.T) { + t.Parallel() + // This is the case the bug affected: after replacing once, the seen map + // must reflect the updated CreatedAt so the third occurrence compares correctly. + batch1 := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "oldest", CreatedAt: t0}, + } + batch2 := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "newest", CreatedAt: t2}, + } + batch3 := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "middle", CreatedAt: t1}, + } + result := deduplicateSessions(nil, batch1) + result = deduplicateSessions(result, batch2) + result = deduplicateSessions(result, batch3) + if len(result) != 1 { + t.Fatalf("got %d sessions, want 1", len(result)) + } + if result[0].Prompt != "newest" { + t.Errorf("got prompt %q, want %q", result[0].Prompt, "newest") + } + }) + + t.Run("mixed unique and duplicate", func(t *testing.T) { + t.Parallel() + existing := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "s1-old", CreatedAt: t0}, + {SessionID: "s2", Prompt: "s2-only", CreatedAt: t0}, + } + incoming := []strategy.RestoredSession{ + {SessionID: "s1", Prompt: "s1-new", CreatedAt: t1}, + {SessionID: "s3", Prompt: "s3-only", CreatedAt: t1}, + } + got := deduplicateSessions(existing, incoming) + if len(got) != 3 { + t.Fatalf("got %d sessions, want 3", len(got)) + } + if got[0].Prompt != "s1-new" { + t.Errorf("s1: got prompt %q, want %q", got[0].Prompt, "s1-new") + } + if got[1].SessionID != "s2" { + t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, "s2") + } + if got[2].SessionID != "s3" { + t.Errorf("got[2].SessionID = %q, want %q", got[2].SessionID, "s3") + } + }) +} + +// TestResumeMultipleCheckpoints_SortsByCreatedAt verifies that resumeMultipleCheckpoints +// sorts checkpoints by CreatedAt ascending before restoring, so that the newest checkpoint +// writes last and wins on disk. This fixes the git CLI squash merge bug where trailers +// appear in reverse chronological order (newest first). +func TestResumeMultipleCheckpoints_SortsByCreatedAt(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, _, _ := setupResumeTestRepo(t, tmpDir, false) + + // Create checkpoints with different timestamps. + // Simulate git CLI squash merge order: newest first in the commit message. + t1 := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) // oldest + t2 := time.Date(2025, 1, 1, 11, 0, 0, 0, time.UTC) + t3 := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) // newest + + cpID1 := createCheckpointOnMetadataBranchFull(t, repo, "session-oldest", id.MustCheckpointID("aaa111bbb222"), t1) + cpID2 := createCheckpointOnMetadataBranchFull(t, repo, "session-middle", id.MustCheckpointID("ccc333ddd444"), t2) + cpID3 := createCheckpointOnMetadataBranchFull(t, repo, "session-newest", id.MustCheckpointID("eee555fff666"), t3) + + metadataTree, err := strategy.GetMetadataBranchTree(repo) + if err != nil { + t.Fatalf("Failed to get metadata branch tree: %v", err) + } + + // Pass checkpoint IDs in reverse chronological order (newest first), + // simulating git CLI squash merge trailer order. + reverseOrderIDs := []id.CheckpointID{cpID3, cpID2, cpID1} + checkpoints := collectCheckpointsByAge(metadataTree, reverseOrderIDs) + + // Verify: after sorting, oldest is first, newest is last + if len(checkpoints) != 3 { + t.Fatalf("got %d checkpoints, want 3", len(checkpoints)) + } + if checkpoints[0].CheckpointID.String() != cpID1.String() { + t.Errorf("checkpoints[0] = %s (want oldest %s)", checkpoints[0].CheckpointID, cpID1) + } + if checkpoints[1].CheckpointID.String() != cpID2.String() { + t.Errorf("checkpoints[1] = %s (want middle %s)", checkpoints[1].CheckpointID, cpID2) + } + if checkpoints[2].CheckpointID.String() != cpID3.String() { + t.Errorf("checkpoints[2] = %s (want newest %s)", checkpoints[2].CheckpointID, cpID3) + } + + // Verify timestamps are actually ascending + if !checkpoints[0].CreatedAt.Before(checkpoints[1].CreatedAt) { + t.Errorf("checkpoints[0].CreatedAt (%v) should be before checkpoints[1].CreatedAt (%v)", + checkpoints[0].CreatedAt, checkpoints[1].CreatedAt) + } + if !checkpoints[1].CreatedAt.Before(checkpoints[2].CreatedAt) { + t.Errorf("checkpoints[1].CreatedAt (%v) should be before checkpoints[2].CreatedAt (%v)", + checkpoints[1].CreatedAt, checkpoints[2].CreatedAt) + } +} + +func TestFindCheckpointInHistory_MultipleCheckpoints(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, w, _ := setupResumeTestRepo(t, tmpDir, false) + + // Create a commit that simulates a squash merge with multiple checkpoint trailers + testFile := filepath.Join(tmpDir, "squash.txt") + if err := os.WriteFile(testFile, []byte("squash content"), 0o644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if _, err := w.Add("squash.txt"); err != nil { + t.Fatalf("Failed to add file: %v", err) + } + + squashMsg := "Soph/test branch (#2)\n* random_letter script\n\nEntire-Checkpoint: 0aa0814d9839\n\n* random color\n\nEntire-Checkpoint: 33fb587b6fbb\n" + _, err := w.Commit(squashMsg, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + if err != nil { + t.Fatalf("Failed to create squash commit: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("Failed to get HEAD: %v", err) + } + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("Failed to get HEAD commit: %v", err) + } + + result := findCheckpointInHistory(headCommit, nil) + + if len(result.checkpointIDs) != 2 { + t.Fatalf("findCheckpointInHistory() returned %d checkpoint IDs, want 2", len(result.checkpointIDs)) + } + if result.checkpointIDs[0].String() != "0aa0814d9839" { + t.Errorf("checkpointIDs[0] = %q, want %q", result.checkpointIDs[0].String(), "0aa0814d9839") + } + if result.checkpointIDs[1].String() != "33fb587b6fbb" { + t.Errorf("checkpointIDs[1] = %q, want %q", result.checkpointIDs[1].String(), "33fb587b6fbb") + } + if result.newerCommitsExist { + t.Error("newerCommitsExist should be false when HEAD has the checkpoints") + } +} + +func TestFindBranchCheckpoint_SquashMergeMultipleCheckpoints(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, w, _ := setupResumeTestRepo(t, tmpDir, false) + + // Create two checkpoints on metadata branch with different session IDs + sessionID1 := "2025-01-01-session-one" + cpID1 := createCheckpointOnMetadataBranch(t, repo, sessionID1) + + sessionID2 := "2025-01-01-session-two" + cpID2 := createCheckpointOnMetadataBranchFull(t, repo, sessionID2, id.MustCheckpointID("def456abc123"), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + + // Create a squash merge commit with both checkpoint trailers + testFile := filepath.Join(tmpDir, "squash.txt") + if err := os.WriteFile(testFile, []byte("squash content"), 0o644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if _, err := w.Add("squash.txt"); err != nil { + t.Fatalf("Failed to add file: %v", err) + } + + squashMsg := fmt.Sprintf("Squash merge (#1)\n* first feature\n\nEntire-Checkpoint: %s\n\n* second feature\n\nEntire-Checkpoint: %s\n", + cpID1.String(), cpID2.String()) + _, err := w.Commit(squashMsg, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + if err != nil { + t.Fatalf("Failed to create squash commit: %v", err) + } + + // Verify findBranchCheckpoints returns both checkpoint IDs + result, err := findBranchCheckpoints(repo, "master") + if err != nil { + t.Fatalf("findBranchCheckpoints() error = %v", err) + } + if len(result.checkpointIDs) != 2 { + t.Fatalf("findBranchCheckpoints() returned %d checkpoint IDs, want 2", len(result.checkpointIDs)) + } + if result.checkpointIDs[0].String() != cpID1.String() { + t.Errorf("checkpointIDs[0] = %q, want %q", result.checkpointIDs[0].String(), cpID1.String()) + } + if result.checkpointIDs[1].String() != cpID2.String() { + t.Errorf("checkpointIDs[1] = %q, want %q", result.checkpointIDs[1].String(), cpID2.String()) + } +} + func TestCheckRemoteMetadata_MetadataExistsOnRemote(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) diff --git a/cmd/entire/cli/trailers/trailers.go b/cmd/entire/cli/trailers/trailers.go index c17c724b9..40060a6c4 100644 --- a/cmd/entire/cli/trailers/trailers.go +++ b/cmd/entire/cli/trailers/trailers.go @@ -138,6 +138,32 @@ func ParseCheckpoint(commitMessage string) (checkpointID.CheckpointID, bool) { return checkpointID.EmptyCheckpointID, false } +// ParseAllCheckpoints extracts all checkpoint IDs from a commit message. +// Returns a slice of CheckpointIDs (may be empty if none found). +// Duplicate IDs are deduplicated while preserving order. +// This is useful for squash merge commits that contain multiple Entire-Checkpoint trailers. +func ParseAllCheckpoints(commitMessage string) []checkpointID.CheckpointID { + matches := checkpointTrailerRegex.FindAllStringSubmatch(commitMessage, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + ids := make([]checkpointID.CheckpointID, 0, len(matches)) + for _, match := range matches { + if len(match) > 1 { + idStr := strings.TrimSpace(match[1]) + if !seen[idStr] { + if cpID, err := checkpointID.NewCheckpointID(idStr); err == nil { + seen[idStr] = true + ids = append(ids, cpID) + } + } + } + } + return ids +} + // ParseAllSessions extracts all session IDs from a commit message. // Returns a slice of session IDs (may be empty if none found). // Duplicate session IDs are deduplicated while preserving order. diff --git a/cmd/entire/cli/trailers/trailers_test.go b/cmd/entire/cli/trailers/trailers_test.go index c6eb23d3c..b750dc07b 100644 --- a/cmd/entire/cli/trailers/trailers_test.go +++ b/cmd/entire/cli/trailers/trailers_test.go @@ -2,6 +2,8 @@ package trailers import ( "testing" + + checkpointID "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" ) func TestFormatMetadata(t *testing.T) { @@ -255,6 +257,66 @@ func TestParseAllSessions(t *testing.T) { } } +func TestParseAllCheckpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + message string + want []string + }{ + { + name: "single checkpoint trailer", + message: "Add feature\n\nEntire-Checkpoint: a1b2c3d4e5f6\n", + want: []string{"a1b2c3d4e5f6"}, + }, + { + name: "no trailer", + message: "Simple commit message", + want: nil, + }, + { + name: "multiple checkpoint trailers from squash merge", + message: "Soph/test branch (#2)\n\n* random_letter script\n\nEntire-Checkpoint: 0aa0814d9839\n\n* random color\n\nEntire-Checkpoint: 33fb587b6fbb\n", + want: []string{"0aa0814d9839", "33fb587b6fbb"}, + }, + { + name: "duplicate checkpoint IDs are deduplicated", + message: "Merge\n\nEntire-Checkpoint: a1b2c3d4e5f6\nEntire-Checkpoint: b2c3d4e5f6a1\nEntire-Checkpoint: a1b2c3d4e5f6\n", + want: []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"}, + }, + { + name: "invalid checkpoint IDs are skipped", + message: "Merge\n\nEntire-Checkpoint: a1b2c3d4e5f6\nEntire-Checkpoint: tooshort\nEntire-Checkpoint: b2c3d4e5f6a1\n", + want: []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"}, + }, + { + name: "mixed with other trailers", + message: "Merge\n\nEntire-Checkpoint: a1b2c3d4e5f6\nEntire-Session: session-1\nEntire-Checkpoint: b2c3d4e5f6a1\n", + want: []string{"a1b2c3d4e5f6", "b2c3d4e5f6a1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ParseAllCheckpoints(tt.message) + if len(got) != len(tt.want) { + t.Errorf("ParseAllCheckpoints() returned %d items, want %d", len(got), len(tt.want)) + t.Errorf("got: %v, want: %v", got, tt.want) + return + } + for i, wantID := range tt.want { + expectedID := checkpointID.MustCheckpointID(wantID) + if got[i] != expectedID { + t.Errorf("ParseAllCheckpoints()[%d] = %v, want %v", i, got[i], expectedID) + } + } + }) + } +} + func TestParseCheckpoint(t *testing.T) { tests := []struct { name string diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go new file mode 100644 index 000000000..04e1ead16 --- /dev/null +++ b/e2e/tests/resume_test.go @@ -0,0 +1,239 @@ +//go:build e2e + +package tests + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/e2e/entire" + "github.com/entireio/cli/e2e/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResumeFromFeatureBranch: agent creates a file on a feature branch and +// user commits, then switches back to main and runs `entire resume feature`. +// Verifies the branch is switched and the session is restored. +func TestResumeFromFeatureBranch(t *testing.T) { + testutil.ForEachAgent(t, 3*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { + mainBranch := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + + // Commit files from `entire enable` so main has a clean working tree + // for branch switching (mirrors a real repo where .gitignore is tracked). + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Enable entire") + + // Do agent work on a feature branch. + s.Git(t, "checkout", "-b", "feature") + + _, err := s.RunPrompt(t, ctx, + "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation, just make the change.") + if err != nil { + t.Fatalf("agent failed: %v", err) + } + + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Add hello doc") + testutil.WaitForCheckpoint(t, s, 15*time.Second) + + // Switch back to main and resume the feature branch. + s.Git(t, "checkout", mainBranch) + + out, err := entire.Resume(s.Dir, "feature") + require.NoError(t, err, "entire resume failed: %s", out) + + current := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + assert.Equal(t, "feature", current, "should be on feature branch after resume") + assert.Contains(t, out, "To continue", "resume output should show resume instructions") + }) +} + +// TestResumeSquashMergeMultipleCheckpoints: two agent prompts on a feature +// branch each get their own commit and checkpoint. The feature branch is +// squash-merged to main and `entire resume` should find and restore both +// sessions. Tests both squash merge message formats: +// +// - GitHub format: trailers appear at the top level in the commit body +// (e.g. "* Add red doc\n\nEntire-Checkpoint: aaa\n\n* Add blue doc\n\nEntire-Checkpoint: bbb") +// - git CLI format: `git merge --squash` nests original commit messages +// (including trailers) indented with 4 spaces inside the squash message +// +// Both formats are tested in a single function to share the expensive agent +// prompts. Main is reset between format tests. +func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { + testutil.ForEachAgent(t, 5*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { + mainBranch := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + + // Commit files from `entire enable` so main has a clean working tree + // for branch switching and squash merging. + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Enable entire") + + // Create feature branch with two agent-assisted commits. + s.Git(t, "checkout", "-b", "feature") + + _, err := s.RunPrompt(t, ctx, + "create a file at docs/red.md with a paragraph about the colour red. Do not ask for confirmation, just make the change.") + if err != nil { + t.Fatalf("prompt 1 failed: %v", err) + } + + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Add red doc") + testutil.WaitForCheckpoint(t, s, 15*time.Second) + cp1Ref := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") + + _, err = s.RunPrompt(t, ctx, + "create a file at docs/blue.md with a paragraph about the colour blue. Do not ask for confirmation, just make the change.") + if err != nil { + t.Fatalf("prompt 2 failed: %v", err) + } + + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Add blue doc") + testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, 15*time.Second) + + // Record checkpoint IDs from both feature branch commits. + cpID1 := testutil.GetCheckpointTrailer(t, s.Dir, "HEAD~1") + cpID2 := testutil.GetCheckpointTrailer(t, s.Dir, "HEAD") + require.NotEmpty(t, cpID1, "first commit should have checkpoint trailer") + require.NotEmpty(t, cpID2, "second commit should have checkpoint trailer") + require.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct") + + // Save main HEAD so we can reset between format tests. + s.Git(t, "checkout", mainBranch) + mainHead := testutil.GitOutput(t, s.Dir, "rev-parse", "HEAD") + + // --- Format 1: GitHub squash merge --- + // GitHub puts each original commit message (including trailers) as + // top-level bullet points in the squash commit body. + s.Git(t, "merge", "--squash", "feature") + githubMsg := fmt.Sprintf( + "Squash merge feature (#1)\n\n* Add red doc\n\nEntire-Checkpoint: %s\n\n* Add blue doc\n\nEntire-Checkpoint: %s", + cpID1, cpID2, + ) + s.Git(t, "commit", "-m", githubMsg) + + out, err := entire.Resume(s.Dir, mainBranch) + require.NoError(t, err, "github format: entire resume failed: %s", out) + assert.Contains(t, out, "Restored 2 sessions", + "github format: squash merge should restore 2 sessions") + + // Reset main to before the squash merge for the next format test. + s.Git(t, "reset", "--hard", mainHead) + + // --- Format 2: git merge --squash --- + // The git CLI nests original commit messages (with trailers) indented + // by 4 spaces inside "Squashed commit of the following:". + s.Git(t, "merge", "--squash", "feature") + + // Sanity-check: the auto-generated SQUASH_MSG should contain both trailers. + squashMsgBytes, err := os.ReadFile(filepath.Join(s.Dir, ".git", "SQUASH_MSG")) + require.NoError(t, err, "read .git/SQUASH_MSG") + squashMsgStr := string(squashMsgBytes) + require.Contains(t, squashMsgStr, cpID1, + "git SQUASH_MSG should contain first checkpoint ID") + require.Contains(t, squashMsgStr, cpID2, + "git SQUASH_MSG should contain second checkpoint ID") + + // Commit using the git-generated squash message directly (not via -m). + // GIT_EDITOR=true prevents git from opening an editor while letting + // it use .git/SQUASH_MSG natively with all hooks running. + commitCmd := exec.Command("git", "commit") + commitCmd.Dir = s.Dir + commitCmd.Env = append(os.Environ(), "ENTIRE_TEST_TTY=0", "GIT_EDITOR=true") + commitOut, commitErr := commitCmd.CombinedOutput() + fmt.Fprintf(s.ConsoleLog, "> git commit (GIT_EDITOR=true)\n%s\n", commitOut) + require.NoError(t, commitErr, "git commit with squash message failed: %s", commitOut) + + out, err = entire.Resume(s.Dir, mainBranch) + require.NoError(t, err, "git-cli format: entire resume failed: %s", out) + assert.Contains(t, out, "Restored 2 sessions", + "git-cli format: squash merge should restore 2 sessions") + }) +} + +// TestResumeNoCheckpointOnBranch: resume on a feature branch that has only +// human commits (no agent interaction). Should switch to the branch and exit +// cleanly with an informational message, not an error. +func TestResumeNoCheckpointOnBranch(t *testing.T) { + testutil.ForEachAgent(t, 1*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { + mainBranch := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + + // Commit files from `entire enable` so main has a clean working tree. + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Enable entire") + + // Create a feature branch with only human commits. + s.Git(t, "checkout", "-b", "no-checkpoint") + require.NoError(t, os.MkdirAll(filepath.Join(s.Dir, "docs"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(s.Dir, "docs", "human.md"), + []byte("# Written by a human\n"), 0o644, + )) + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Human-only commit") + + // Switch back to main and try to resume the feature branch. + s.Git(t, "checkout", mainBranch) + + out, err := entire.Resume(s.Dir, "no-checkpoint") + require.NoError(t, err, "resume should not error for missing checkpoints: %s", out) + + assert.Contains(t, out, "No Entire checkpoint found", + "should inform user no checkpoint exists on branch") + }) +} + +// TestResumeOlderCheckpointWithNewerCommits: agent creates a file on a feature +// branch and user commits (checkpoint created), then user adds a human-only +// commit on top. `entire resume --force` should still find and restore the +// older checkpoint despite the newer commits without checkpoints. +func TestResumeOlderCheckpointWithNewerCommits(t *testing.T) { + testutil.ForEachAgent(t, 3*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { + mainBranch := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + + // Commit files from `entire enable` so main has a clean working tree. + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Enable entire") + + // Do agent work on a feature branch. + s.Git(t, "checkout", "-b", "feature") + + _, err := s.RunPrompt(t, ctx, + "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation, just make the change.") + if err != nil { + t.Fatalf("agent failed: %v", err) + } + + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Add hello doc") + testutil.WaitForCheckpoint(t, s, 15*time.Second) + + // Add a human-only commit on top (no agent involvement, no checkpoint). + require.NoError(t, os.MkdirAll(filepath.Join(s.Dir, "notes"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(s.Dir, "notes", "todo.md"), + []byte("# TODO\n- something\n"), 0o644, + )) + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Human-only follow-up") + + // Switch back to main and resume (--force is always passed by + // entire.Resume, which bypasses the "older checkpoint" confirmation). + s.Git(t, "checkout", mainBranch) + + out, err := entire.Resume(s.Dir, "feature") + require.NoError(t, err, "entire resume failed: %s", out) + + current := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + assert.Equal(t, "feature", current, "should be on feature branch after resume") + assert.Contains(t, out, "To continue", "should restore the older checkpoint session") + }) +}