From f8b25af81ed9e6eac0eb55ac7e31495e193d6e42 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 13:57:10 -0800 Subject: [PATCH 01/18] Update branchCheckpointResult to support multiple checkpoint IDs Change checkpointID field to checkpointIDs slice, update findBranchCheckpoint and findCheckpointInHistory to use ParseAllCheckpoints, and add test for squash merge commits with multiple Entire-Checkpoint trailers. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: e49c84c2d596 --- cmd/entire/cli/resume.go | 14 +++++----- cmd/entire/cli/resume_test.go | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 31d117034..705fdfcf8 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -131,7 +131,7 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) 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 +153,7 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) } } - checkpointID := result.checkpointID + checkpointID := result.checkpointIDs[0] // Get metadata branch tree for lookups metadataTree, err := strategy.GetMetadataBranchTree(repo) @@ -174,7 +174,7 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) // branchCheckpointResult contains the result of searching for a checkpoint on a branch. type branchCheckpointResult struct { - checkpointID id.CheckpointID + checkpointIDs []id.CheckpointID commitHash string commitMessage string newerCommitsExist bool // true if there are branch-only commits (not merge commits) without checkpoints @@ -199,8 +199,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 @@ -268,8 +268,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 diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index c992db4a0..9ef91bf97 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -432,6 +432,57 @@ func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessio return checkpointID } +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 TestCheckRemoteMetadata_MetadataExistsOnRemote(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) From 8b0bcfa00c9048b2aa4915a4431b9ae3dadd8f7c Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 14:06:13 -0800 Subject: [PATCH 02/18] Add ParseAllCheckpoints and resumeMultipleCheckpoints Add ParseAllCheckpoints() to trailers package for extracting all Entire-Checkpoint trailers from squash merge commits. Add resumeMultipleCheckpoints() to resume.go that iterates over all checkpoint IDs, restores sessions for each, and displays aggregated resume commands. Refactor test helper to support custom checkpoint IDs. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 43abb5b45ab5 --- cmd/entire/cli/resume.go | 150 +++++++++++++++++++++++ cmd/entire/cli/resume_test.go | 61 ++++++++- cmd/entire/cli/trailers/trailers.go | 26 ++++ cmd/entire/cli/trailers/trailers_test.go | 62 ++++++++++ 4 files changed, 296 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 705fdfcf8..0ed4f698d 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -153,6 +153,12 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) } } + // 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 @@ -172,6 +178,150 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) return resumeSession(ctx, metadata.SessionID, checkpointID, force) } +// 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 + } + } + } + + strat := GetStrategy(ctx) + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("failed to get worktree root: %w", err) + } + + var allSessions []strategy.RestoredSession + + for _, cpID := range checkpointIDs { + metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) + if metaErr != nil { + logging.Debug(logCtx, "skipping checkpoint without metadata", + slog.String("checkpoint_id", cpID.String()), + slog.String("error", metaErr.Error()), + ) + continue + } + + ag, agErr := strategy.ResolveAgentForRewind(metadata.Agent) + if agErr != nil { + logging.Debug(logCtx, "skipping checkpoint with unknown agent", + slog.String("checkpoint_id", cpID.String()), + slog.String("error", agErr.Error()), + ) + continue + } + + sessionDir, dirErr := ag.GetSessionDir(repoRoot) + if dirErr != nil { + logging.Debug(logCtx, "skipping checkpoint: cannot determine session dir", + slog.String("checkpoint_id", cpID.String()), + slog.String("error", dirErr.Error()), + ) + continue + } + if mkErr := os.MkdirAll(sessionDir, 0o700); mkErr != nil { + logging.Debug(logCtx, "skipping checkpoint: cannot create session dir", + slog.String("checkpoint_id", cpID.String()), + slog.String("error", mkErr.Error()), + ) + continue + } + + point := strategy.RewindPoint{ + IsLogsOnly: true, + CheckpointID: cpID, + Agent: metadata.Agent, + } + + sessions, restoreErr := strat.RestoreLogsOnly(ctx, point, force) + if restoreErr != nil || len(sessions) == 0 { + logging.Debug(logCtx, "skipping checkpoint: restore failed", + slog.String("checkpoint_id", cpID.String()), + ) + continue + } + + allSessions = append(allSessions, sessions...) + } + + if len(allSessions) == 0 { + fmt.Fprintf(os.Stderr, "No session metadata found for checkpoints in this commit\n") + return nil + } + + // Sort by CreatedAt (same as resumeSession) + sort.Slice(allSessions, func(i, j int) bool { + return allSessions[i].CreatedAt.Before(allSessions[j].CreatedAt) + }) + + logging.Debug(logCtx, "resume multiple checkpoints completed", + slog.Int("checkpoint_count", len(checkpointIDs)), + slog.Int("session_count", len(allSessions)), + ) + + // Display resume commands (same format as resumeSession) + if len(allSessions) > 1 { + fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(allSessions)) + } else { + fmt.Fprintf(os.Stderr, "Session: %s\n", allSessions[0].SessionID) + fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") + } + for i, sess := range allSessions { + sessionAgent, agErr := strategy.ResolveAgentForRewind(sess.Agent) + if agErr != nil { + return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, agErr) + } + cmd := sessionAgent.FormatResumeCommand(sess.SessionID) + + if len(allSessions) > 1 { + if i == len(allSessions)-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) + } + } + } + + return nil +} + // branchCheckpointResult contains the result of searching for a checkpoint on a branch. type branchCheckpointResult struct { checkpointIDs []id.CheckpointID diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 9ef91bf97..af4371e18 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -267,12 +267,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"). Returns the checkpoint ID. func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessionID string) id.CheckpointID { t.Helper() + return createCheckpointOnMetadataBranchWithID(t, repo, sessionID, id.MustCheckpointID("abc123def456")) +} - checkpointID := id.MustCheckpointID("abc123def456") // Fixed ID for testing +// createCheckpointOnMetadataBranchWithID creates a checkpoint on the entire/checkpoints/v1 branch +// with a caller-specified checkpoint ID. Returns the checkpoint ID. +func createCheckpointOnMetadataBranchWithID(t *testing.T, repo *git.Repository, sessionID string, checkpointID id.CheckpointID) id.CheckpointID { + t.Helper() // Get existing metadata branch or create it if err := strategy.EnsureMetadataBranch(repo); err != nil { @@ -483,6 +488,56 @@ func TestFindCheckpointInHistory_MultipleCheckpoints(t *testing.T) { } } +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 := createCheckpointOnMetadataBranchWithID(t, repo, sessionID2, id.MustCheckpointID("def456abc123")) + + // 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 findBranchCheckpoint returns both checkpoint IDs + result, err := findBranchCheckpoint(repo, "master") + if err != nil { + t.Fatalf("findBranchCheckpoint() error = %v", err) + } + if len(result.checkpointIDs) != 2 { + t.Fatalf("findBranchCheckpoint() 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 From c9405b909237e227ae063c1101a917ebf7ea354e Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 14:29:35 -0800 Subject: [PATCH 03/18] Add integration test for multi-checkpoint resume Entire-Checkpoint: c585b5e466a1 --- .../cli/integration_test/resume_test.go | 108 ++++++++++++++++++ cmd/entire/cli/integration_test/testenv.go | 36 ++++++ 2 files changed, 144 insertions(+) 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 900683cb6..13070db75 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() From 5ddc24e5ae18dc6736b235978b71bd540aa06b28 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 15:22:40 -0800 Subject: [PATCH 04/18] Extract displayRestoredSessions to deduplicate session display logic The sorting, header formatting, and resume command printing was duplicated between resumeSession and resumeMultipleCheckpoints. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 0ff68845c557 --- cmd/entire/cli/resume.go | 92 ++++++++++------------------------------ 1 file changed, 22 insertions(+), 70 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 0ed4f698d..925781aa7 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -272,54 +272,12 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp return nil } - // Sort by CreatedAt (same as resumeSession) - sort.Slice(allSessions, func(i, j int) bool { - return allSessions[i].CreatedAt.Before(allSessions[j].CreatedAt) - }) - logging.Debug(logCtx, "resume multiple checkpoints completed", slog.Int("checkpoint_count", len(checkpointIDs)), slog.Int("session_count", len(allSessions)), ) - // Display resume commands (same format as resumeSession) - if len(allSessions) > 1 { - fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(allSessions)) - } else { - fmt.Fprintf(os.Stderr, "Session: %s\n", allSessions[0].SessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - } - for i, sess := range allSessions { - sessionAgent, agErr := strategy.ResolveAgentForRewind(sess.Agent) - if agErr != nil { - return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, agErr) - } - cmd := sessionAgent.FormatResumeCommand(sess.SessionID) - - if len(allSessions) > 1 { - if i == len(allSessions)-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) - } - } - } - - return nil + return displayRestoredSessions(allSessions) } // branchCheckpointResult contains the result of searching for a checkpoint on a branch. @@ -570,24 +528,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.Slice(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 { @@ -595,26 +557,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) } } From 3c80fab2ac08fddbdb5d5b225f7d74024f567b30 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 15:24:42 -0800 Subject: [PATCH 05/18] Remove redundant agent resolution gate in resumeMultipleCheckpoints RestoreLogsOnly already resolves agents per-session from session-level metadata, so an unknown checkpoint-level agent shouldn't block the restore attempt. The session dir creation was also redundant since RestoreLogsOnly handles it internally. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 831e4afa5e83 --- cmd/entire/cli/resume.go | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 925781aa7..ae4f9edca 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -208,11 +208,6 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } strat := GetStrategy(ctx) - repoRoot, err := paths.WorktreeRoot(ctx) - if err != nil { - return fmt.Errorf("failed to get worktree root: %w", err) - } - var allSessions []strategy.RestoredSession for _, cpID := range checkpointIDs { @@ -225,31 +220,6 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp continue } - ag, agErr := strategy.ResolveAgentForRewind(metadata.Agent) - if agErr != nil { - logging.Debug(logCtx, "skipping checkpoint with unknown agent", - slog.String("checkpoint_id", cpID.String()), - slog.String("error", agErr.Error()), - ) - continue - } - - sessionDir, dirErr := ag.GetSessionDir(repoRoot) - if dirErr != nil { - logging.Debug(logCtx, "skipping checkpoint: cannot determine session dir", - slog.String("checkpoint_id", cpID.String()), - slog.String("error", dirErr.Error()), - ) - continue - } - if mkErr := os.MkdirAll(sessionDir, 0o700); mkErr != nil { - logging.Debug(logCtx, "skipping checkpoint: cannot create session dir", - slog.String("checkpoint_id", cpID.String()), - slog.String("error", mkErr.Error()), - ) - continue - } - point := strategy.RewindPoint{ IsLogsOnly: true, CheckpointID: cpID, From c1e7316e8c0cc4f01def9ef275aa0bcf966c3439 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 15:26:13 -0800 Subject: [PATCH 06/18] Include error details in debug log when RestoreLogsOnly fails Split the error and empty-sessions cases into separate log entries so the actual failure reason is visible in debug logs. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 932c2642d8ac --- cmd/entire/cli/resume.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index ae4f9edca..89fdd340f 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -227,9 +227,16 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } sessions, restoreErr := strat.RestoreLogsOnly(ctx, point, force) - if restoreErr != nil || len(sessions) == 0 { + if restoreErr != nil { logging.Debug(logCtx, "skipping checkpoint: restore failed", slog.String("checkpoint_id", cpID.String()), + slog.String("error", restoreErr.Error()), + ) + continue + } + if len(sessions) == 0 { + logging.Debug(logCtx, "skipping checkpoint: no sessions restored", + slog.String("checkpoint_id", cpID.String()), ) continue } From 674adb689f906a0e498ee2262d6f7669f26881b2 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 15:36:09 -0800 Subject: [PATCH 07/18] Deduplicate sessions by SessionID in resumeMultipleCheckpoints When a single session spans multiple commits, different checkpoint IDs can contain the same session. In a squash merge this would produce duplicate resume commands. Keep the entry with the latest CreatedAt. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: e92b8f8525e9 --- cmd/entire/cli/resume.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 89fdd340f..3eac6e289 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" @@ -207,8 +208,14 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } } + type sessionEntry struct { + index int + CreatedAt time.Time + } + strat := GetStrategy(ctx) var allSessions []strategy.RestoredSession + seen := make(map[string]sessionEntry) for _, cpID := range checkpointIDs { metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) @@ -241,7 +248,17 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp continue } - allSessions = append(allSessions, sessions...) + for _, sess := range sessions { + if prev, exists := seen[sess.SessionID]; exists { + // Keep the one with the later CreatedAt (more complete transcript) + if sess.CreatedAt.After(prev.CreatedAt) { + allSessions[seen[sess.SessionID].index] = sess + } + } else { + seen[sess.SessionID] = sessionEntry{index: len(allSessions), CreatedAt: sess.CreatedAt} + allSessions = append(allSessions, sess) + } + } } if len(allSessions) == 0 { From e1cee2f4a19500352ddfd1c4b0b64eed2d05b9c9 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 15:54:42 -0800 Subject: [PATCH 08/18] Extract deduplicateSessions and fix seen map staleness bug The inline dedup logic in resumeMultipleCheckpoints did not update the seen map's CreatedAt after replacing a session, so a third occurrence could incorrectly overwrite the newest entry. Extract to a standalone function with a correct update and add targeted unit tests. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 7132c7b6328a --- cmd/entire/cli/resume.go | 48 +++++++++------ cmd/entire/cli/resume_test.go | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 17 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 3eac6e289..49d62b055 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -208,14 +208,8 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } } - type sessionEntry struct { - index int - CreatedAt time.Time - } - strat := GetStrategy(ctx) var allSessions []strategy.RestoredSession - seen := make(map[string]sessionEntry) for _, cpID := range checkpointIDs { metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) @@ -248,17 +242,7 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp continue } - for _, sess := range sessions { - if prev, exists := seen[sess.SessionID]; exists { - // Keep the one with the later CreatedAt (more complete transcript) - if sess.CreatedAt.After(prev.CreatedAt) { - allSessions[seen[sess.SessionID].index] = sess - } - } else { - seen[sess.SessionID] = sessionEntry{index: len(allSessions), CreatedAt: sess.CreatedAt} - allSessions = append(allSessions, sess) - } - } + allSessions = deduplicateSessions(allSessions, sessions) } if len(allSessions) == 0 { @@ -274,6 +258,36 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp return displayRestoredSessions(allSessions) } +// 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 +} + // branchCheckpointResult contains the result of searching for a checkpoint on a branch. type branchCheckpointResult struct { checkpointIDs []id.CheckpointID diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index af4371e18..6c50939fb 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" @@ -437,6 +438,114 @@ func createCheckpointOnMetadataBranchWithID(t *testing.T, repo *git.Repository, 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") + } + }) +} + func TestFindCheckpointInHistory_MultipleCheckpoints(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) From f5d7ee56cd5fa6dd9f1682617428918d0c4bb129 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 26 Feb 2026 16:49:54 -0800 Subject: [PATCH 09/18] Update cmd/entire/cli/trailers/trailers_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/trailers/trailers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/entire/cli/trailers/trailers_test.go b/cmd/entire/cli/trailers/trailers_test.go index b750dc07b..b0026e14f 100644 --- a/cmd/entire/cli/trailers/trailers_test.go +++ b/cmd/entire/cli/trailers/trailers_test.go @@ -298,6 +298,7 @@ func TestParseAllCheckpoints(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() From 4271b62088f8f7ca81396540c9a5f2ef266d54b4 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 2 Mar 2026 17:56:31 -0800 Subject: [PATCH 10/18] Sort checkpoints based on timestamp before trying to restore them. Entire-Checkpoint: 27bbbac46736 --- cmd/entire/cli/resume.go | 25 ++++++++--- cmd/entire/cli/resume_test.go | 85 +++++++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 49d62b055..ef11d1f51 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -208,9 +208,8 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } } - strat := GetStrategy(ctx) - var allSessions []strategy.RestoredSession - + // Phase 1: Read metadata for all checkpoints + var checkpoints []*strategy.CheckpointInfo for _, cpID := range checkpointIDs { metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) if metaErr != nil { @@ -220,24 +219,36 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp ) continue } + checkpoints = append(checkpoints, metadata) + } + + // Phase 2: Sort by CreatedAt ascending (oldest first → newest writes last and wins on disk) + sort.Slice(checkpoints, func(i, j int) bool { + return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) + }) + // Phase 3: Iterate sorted checkpoints and restore + strat := GetStrategy(ctx) + var allSessions []strategy.RestoredSession + + for _, cp := range checkpoints { point := strategy.RewindPoint{ IsLogsOnly: true, - CheckpointID: cpID, - Agent: metadata.Agent, + 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", cpID.String()), + 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", cpID.String()), + slog.String("checkpoint_id", cp.CheckpointID.String()), ) continue } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 6c50939fb..676928e05 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -269,15 +270,15 @@ func TestRunResume_UncommittedChanges(t *testing.T) { } // createCheckpointOnMetadataBranch creates a checkpoint on the entire/checkpoints/v1 branch -// with a default checkpoint ID ("abc123def456"). Returns the checkpoint ID. +// with a default checkpoint ID ("abc123def456") and default timestamp. func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessionID string) id.CheckpointID { t.Helper() - return createCheckpointOnMetadataBranchWithID(t, repo, sessionID, id.MustCheckpointID("abc123def456")) + return createCheckpointOnMetadataBranchFull(t, repo, sessionID, id.MustCheckpointID("abc123def456"), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) } -// createCheckpointOnMetadataBranchWithID creates a checkpoint on the entire/checkpoints/v1 branch -// with a caller-specified checkpoint ID. Returns the checkpoint ID. -func createCheckpointOnMetadataBranchWithID(t *testing.T, repo *git.Repository, sessionID string, checkpointID id.CheckpointID) id.CheckpointID { +// 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 @@ -300,8 +301,8 @@ func createCheckpointOnMetadataBranchWithID(t *testing.T, repo *git.Repository, 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() @@ -546,6 +547,74 @@ func TestDeduplicateSessions(t *testing.T) { }) } +// 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) + + // Read metadata in reverse order (simulating git CLI squash merge trailer order) + metadataTree, err := strategy.GetMetadataBranchTree(repo) + if err != nil { + t.Fatalf("Failed to get metadata branch tree: %v", err) + } + + // Provide checkpoint IDs in reverse chronological order (newest first) + reverseOrderIDs := []id.CheckpointID{cpID3, cpID2, cpID1} + + var checkpoints []*strategy.CheckpointInfo + for _, cpID := range reverseOrderIDs { + metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) + if metaErr != nil { + t.Fatalf("Failed to read metadata for %s: %v", cpID, metaErr) + } + checkpoints = append(checkpoints, metadata) + } + + // Sort by CreatedAt ascending (same logic as resumeMultipleCheckpoints) + sort.Slice(checkpoints, func(i, j int) bool { + return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) + }) + + // 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) @@ -608,7 +677,7 @@ func TestFindBranchCheckpoint_SquashMergeMultipleCheckpoints(t *testing.T) { cpID1 := createCheckpointOnMetadataBranch(t, repo, sessionID1) sessionID2 := "2025-01-01-session-two" - cpID2 := createCheckpointOnMetadataBranchWithID(t, repo, sessionID2, id.MustCheckpointID("def456abc123")) + 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") From 652f3f0dd74c67c2bfb06674fcb1f65d0f6bf5d0 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 10:03:18 -0800 Subject: [PATCH 11/18] Remove collectCheckpointsByAge duplication Entire-Checkpoint: 9c67547d59c3 --- cmd/entire/cli/resume.go | 42 +++++++++++++----------- cmd/entire/cli/resume_test.go | 20 ++--------- cmd/entire/cli/trailers/trailers_test.go | 1 - 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index ef11d1f51..9c95d3dfc 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -208,26 +208,11 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } } - // Phase 1: Read metadata for all checkpoints - var checkpoints []*strategy.CheckpointInfo - for _, cpID := range checkpointIDs { - metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) - if metaErr != nil { - logging.Debug(logCtx, "skipping checkpoint without metadata", - slog.String("checkpoint_id", cpID.String()), - slog.String("error", metaErr.Error()), - ) - continue - } - checkpoints = append(checkpoints, metadata) - } - - // Phase 2: Sort by CreatedAt ascending (oldest first → newest writes last and wins on disk) - sort.Slice(checkpoints, func(i, j int) bool { - return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) - }) + // Read metadata for all checkpoints and sort by CreatedAt ascending + // (oldest first → newest writes last and wins on disk) + checkpoints := collectCheckpointsByAge(metadataTree, checkpointIDs) - // Phase 3: Iterate sorted checkpoints and restore + // Iterate sorted checkpoints and restore strat := GetStrategy(ctx) var allSessions []strategy.RestoredSession @@ -269,6 +254,25 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp 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. diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 676928e05..bceff52ac 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "testing" "time" @@ -567,28 +566,15 @@ func TestResumeMultipleCheckpoints_SortsByCreatedAt(t *testing.T) { cpID2 := createCheckpointOnMetadataBranchFull(t, repo, "session-middle", id.MustCheckpointID("ccc333ddd444"), t2) cpID3 := createCheckpointOnMetadataBranchFull(t, repo, "session-newest", id.MustCheckpointID("eee555fff666"), t3) - // Read metadata in reverse order (simulating git CLI squash merge trailer order) metadataTree, err := strategy.GetMetadataBranchTree(repo) if err != nil { t.Fatalf("Failed to get metadata branch tree: %v", err) } - // Provide checkpoint IDs in reverse chronological order (newest first) + // Pass checkpoint IDs in reverse chronological order (newest first), + // simulating git CLI squash merge trailer order. reverseOrderIDs := []id.CheckpointID{cpID3, cpID2, cpID1} - - var checkpoints []*strategy.CheckpointInfo - for _, cpID := range reverseOrderIDs { - metadata, metaErr := strategy.ReadCheckpointMetadata(metadataTree, cpID.Path()) - if metaErr != nil { - t.Fatalf("Failed to read metadata for %s: %v", cpID, metaErr) - } - checkpoints = append(checkpoints, metadata) - } - - // Sort by CreatedAt ascending (same logic as resumeMultipleCheckpoints) - sort.Slice(checkpoints, func(i, j int) bool { - return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) - }) + checkpoints := collectCheckpointsByAge(metadataTree, reverseOrderIDs) // Verify: after sorting, oldest is first, newest is last if len(checkpoints) != 3 { diff --git a/cmd/entire/cli/trailers/trailers_test.go b/cmd/entire/cli/trailers/trailers_test.go index b0026e14f..b750dc07b 100644 --- a/cmd/entire/cli/trailers/trailers_test.go +++ b/cmd/entire/cli/trailers/trailers_test.go @@ -298,7 +298,6 @@ func TestParseAllCheckpoints(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() From c0b9c95fb4c95de50af6c9ca4320d3b349be9794 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 11:22:00 -0800 Subject: [PATCH 12/18] Rename branchCheckpointResult since its functionality changed slightly Entire-Checkpoint: 8664f2d41cb3 --- cmd/entire/cli/resume.go | 16 ++++++++-------- cmd/entire/cli/resume_test.go | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 9c95d3dfc..cf74d24b6 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -128,7 +128,7 @@ 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 } @@ -303,8 +303,8 @@ func deduplicateSessions(existing, incoming []strategy.RestoredSession) []strate return existing } -// branchCheckpointResult contains the result of searching for a checkpoint on a branch. -type branchCheckpointResult struct { +// branchCheckpointsResult contains the result of searching for checkpoints on a branch. +type branchCheckpointsResult struct { checkpointIDs []id.CheckpointID commitHash string commitMessage string @@ -312,11 +312,11 @@ type branchCheckpointResult struct { 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() @@ -385,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 diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index bceff52ac..826117bb1 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -686,13 +686,13 @@ func TestFindBranchCheckpoint_SquashMergeMultipleCheckpoints(t *testing.T) { t.Fatalf("Failed to create squash commit: %v", err) } - // Verify findBranchCheckpoint returns both checkpoint IDs - result, err := findBranchCheckpoint(repo, "master") + // Verify findBranchCheckpoints returns both checkpoint IDs + result, err := findBranchCheckpoints(repo, "master") if err != nil { - t.Fatalf("findBranchCheckpoint() error = %v", err) + t.Fatalf("findBranchCheckpoints() error = %v", err) } if len(result.checkpointIDs) != 2 { - t.Fatalf("findBranchCheckpoint() returned %d checkpoint IDs, want 2", len(result.checkpointIDs)) + 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()) From a342bef4d86a0cd6a23ceeefb6ac698e5907bd1b Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 11:43:52 -0800 Subject: [PATCH 13/18] Update cmd/entire/cli/resume.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/resume.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index ef11d1f51..c17c6f4ef 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -223,7 +223,11 @@ func resumeMultipleCheckpoints(ctx context.Context, repo *git.Repository, checkp } // Phase 2: Sort by CreatedAt ascending (oldest first → newest writes last and wins on disk) - sort.Slice(checkpoints, func(i, j int) bool { + sort.SliceStable(checkpoints, func(i, j int) bool { + if checkpoints[i].CreatedAt.Equal(checkpoints[j].CreatedAt) { + // Deterministic tie-breaker when CreatedAt timestamps are equal + return checkpoints[i].CheckpointID.String() < checkpoints[j].CheckpointID.String() + } return checkpoints[i].CreatedAt.Before(checkpoints[j].CreatedAt) }) From 17640631d72ac9c9623d0384dc42738ce119854c Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 11:44:27 -0800 Subject: [PATCH 14/18] Update cmd/entire/cli/resume.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/resume.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index c17c6f4ef..961b1d8bd 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -562,7 +562,10 @@ func resumeSession(ctx context.Context, sessionID string, checkpointID id.Checkp // 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.Slice(sessions, func(i, j int) bool { + sort.SliceStable(sessions, func(i, j int) bool { + if sessions[i].CreatedAt.Equal(sessions[j].CreatedAt) { + return sessions[i].SessionID < sessions[j].SessionID + } return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) }) From 8e9b25b22ea0c3fd45a6c519c403483f08f89e6a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 11:49:42 -0800 Subject: [PATCH 15/18] Remove tie breaker Entire-Checkpoint: dc311b24d1d0 --- cmd/entire/cli/resume.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index f465e9bae..ebfc65908 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -563,9 +563,6 @@ func resumeSession(ctx context.Context, sessionID string, checkpointID id.Checkp // Used by both resumeSession (single checkpoint) and resumeMultipleCheckpoints (squash merge). func displayRestoredSessions(sessions []strategy.RestoredSession) error { sort.SliceStable(sessions, func(i, j int) bool { - if sessions[i].CreatedAt.Equal(sessions[j].CreatedAt) { - return sessions[i].SessionID < sessions[j].SessionID - } return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) }) From 8df9e502b8d1ae362642756913b9d081f23a71d4 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 14:58:03 -0800 Subject: [PATCH 16/18] Add e2e tests for `entire resume` Entire-Checkpoint: f0894592c6ef --- e2e/tests/resume_test.go | 214 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 e2e/tests/resume_test.go diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go new file mode 100644 index 000000000..04c7207c7 --- /dev/null +++ b/e2e/tests/resume_test.go @@ -0,0 +1,214 @@ +//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 and user commits, +// then user switches to a temp branch and resumes back to the original branch. +// Verifies the session is restored and the branch is correct after resume. +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") + + _, 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 away from the branch. + s.Git(t, "checkout", "-b", "temp-branch") + + // Resume back to the original branch. + out, err := entire.Resume(s.Dir, mainBranch) + require.NoError(t, err, "entire resume failed: %s", out) + + current := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + assert.Equal(t, mainBranch, current, "should be back on %s after resume", mainBranch) + 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") + + // 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 branch that has only human +// commits. Should 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") + + // Create a 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 and try to resume the no-checkpoint 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 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. +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") + + _, 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 away and resume (--force is always passed by entire.Resume, + // which bypasses the "older checkpoint" confirmation prompt). + s.Git(t, "checkout", "-b", "temp-branch") + + out, err := entire.Resume(s.Dir, mainBranch) + require.NoError(t, err, "entire resume failed: %s", out) + + current := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + assert.Equal(t, mainBranch, current, "should be on %s after resume", mainBranch) + assert.Contains(t, out, "To continue", "should restore the older checkpoint session") + }) +} From daae0fe59ba634af9467dd067de21efa0718ffcc Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 15:06:52 -0800 Subject: [PATCH 17/18] Ensure resume tests operate on feature branches. Entire-Checkpoint: dc723823f0b4 --- e2e/tests/resume_test.go | 60 ++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go index 04c7207c7..208effd61 100644 --- a/e2e/tests/resume_test.go +++ b/e2e/tests/resume_test.go @@ -17,13 +17,21 @@ import ( "github.com/stretchr/testify/require" ) -// TestResumeFromFeatureBranch: agent creates a file and user commits, -// then user switches to a temp branch and resumes back to the original branch. -// Verifies the session is restored and the branch is correct after resume. +// 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 { @@ -34,15 +42,14 @@ func TestResumeFromFeatureBranch(t *testing.T) { s.Git(t, "commit", "-m", "Add hello doc") testutil.WaitForCheckpoint(t, s, 15*time.Second) - // Switch away from the branch. - s.Git(t, "checkout", "-b", "temp-branch") + // Switch back to main and resume the feature branch. + s.Git(t, "checkout", mainBranch) - // Resume back to the original branch. - out, err := entire.Resume(s.Dir, 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, mainBranch, current, "should be back on %s after resume", mainBranch) + assert.Equal(t, "feature", current, "should be on feature branch after resume") assert.Contains(t, out, "To continue", "resume output should show resume instructions") }) } @@ -147,13 +154,18 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { }) } -// TestResumeNoCheckpointOnBranch: resume on a branch that has only human -// commits. Should exit cleanly with an informational message, not an error. +// 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") - // Create a branch with only human commits. + // 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( @@ -163,7 +175,7 @@ func TestResumeNoCheckpointOnBranch(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Human-only commit") - // Switch back and try to resume the no-checkpoint branch. + // Switch back to main and try to resume the feature branch. s.Git(t, "checkout", mainBranch) out, err := entire.Resume(s.Dir, "no-checkpoint") @@ -174,13 +186,21 @@ func TestResumeNoCheckpointOnBranch(t *testing.T) { }) } -// TestResumeOlderCheckpointWithNewerCommits: agent creates a file 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. +// 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 { @@ -200,15 +220,15 @@ func TestResumeOlderCheckpointWithNewerCommits(t *testing.T) { s.Git(t, "add", ".") s.Git(t, "commit", "-m", "Human-only follow-up") - // Switch away and resume (--force is always passed by entire.Resume, - // which bypasses the "older checkpoint" confirmation prompt). - s.Git(t, "checkout", "-b", "temp-branch") + // 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, 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, mainBranch, current, "should be on %s after resume", mainBranch) + assert.Equal(t, "feature", current, "should be on feature branch after resume") assert.Contains(t, out, "To continue", "should restore the older checkpoint session") }) } From a946b3b4e4b7fb66d38bcdbb67dd56637a6d9b25 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 3 Mar 2026 15:22:00 -0800 Subject: [PATCH 18/18] Ensure main branch is clean and doesn't contain uncommitted files Entire-Checkpoint: af287eac4686 --- e2e/tests/resume_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go index 208effd61..04e1ead16 100644 --- a/e2e/tests/resume_test.go +++ b/e2e/tests/resume_test.go @@ -70,6 +70,11 @@ 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")