diff --git a/cmd/entire/cli/clean.go b/cmd/entire/cli/clean.go index c8d726530..fabc95bc1 100644 --- a/cmd/entire/cli/clean.go +++ b/cmd/entire/cli/clean.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/charmbracelet/huh" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -40,21 +41,24 @@ This command finds and removes orphaned data from any strategy: Cached transcripts and other temporary data. Safe to delete when no active sessions are using them. -Default: shows a preview of items that would be deleted. -With --force, actually deletes the orphaned items. +Default: shows a preview and asks for confirmation before deleting. +With --force, deletes without prompting. The entire/checkpoints/v1 branch itself is never deleted.`, RunE: func(cmd *cobra.Command, _ []string) error { - return runClean(cmd.Context(), cmd.OutOrStdout(), forceFlag) + return runClean(cmd.Context(), cmd, forceFlag) }, } - cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Actually delete items (default: dry run)") + cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Delete without prompting for confirmation") return cmd } -func runClean(ctx context.Context, w io.Writer, force bool) error { +func runClean(ctx context.Context, cmd *cobra.Command, force bool) error { + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + // Initialize logging so structured logs go to .entire/logs/ instead of stderr. // Error is non-fatal: if logging init fails, logs go to stderr (acceptable fallback). logging.SetLogLevelGetter(GetLogLevel) @@ -72,10 +76,47 @@ func runClean(ctx context.Context, w io.Writer, force bool) error { tempFiles, err := listTempFiles(ctx) if err != nil { // Non-fatal: continue with other cleanup items - fmt.Fprintf(w, "Warning: failed to list temp files: %v\n", err) + fmt.Fprintf(errW, "Warning: failed to list temp files: %v\n", err) + } + + // Force mode: skip preview and confirmation + if force { + return runCleanWithItems(ctx, w, true, items, tempFiles) + } + + // Show preview + if err := runCleanWithItems(ctx, w, false, items, tempFiles); err != nil { + return err } - return runCleanWithItems(ctx, w, force, items, tempFiles) + // If nothing to clean, we're done (preview already printed the message) + totalItems := len(items) + len(tempFiles) + if totalItems == 0 { + return nil + } + + // Interactive confirmation + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Delete these items?"). + Affirmative("Yes, delete"). + Negative("Cancel"). + Value(&confirmed), + ), + ) + + if err := form.Run(); err != nil { + return handleFormCancellation(w, "Clean", err) + } + + if !confirmed { + fmt.Fprintln(w, "Clean cancelled.") + return nil + } + + return runCleanWithItems(ctx, w, true, items, tempFiles) } // listTempFiles returns files in .entire/tmp/ that are safe to delete, @@ -172,7 +213,7 @@ func runCleanWithItems(ctx context.Context, w io.Writer, force bool, items []str // Preview mode (default) if !force { totalItems := len(items) + len(tempFiles) - fmt.Fprintf(w, "Found %d items to clean:\n\n", totalItems) + fmt.Fprintf(w, "Found %d %s to clean:\n\n", totalItems, itemWord(totalItems)) if len(branches) > 0 { fmt.Fprintf(w, "Shadow branches (%d):\n", len(branches)) @@ -206,7 +247,7 @@ func runCleanWithItems(ctx context.Context, w io.Writer, force bool, items []str fmt.Fprintln(w) } - fmt.Fprintln(w, "Run with --force to delete these items.") + fmt.Fprintln(w, "Use --force to skip this prompt.") return nil } @@ -224,70 +265,78 @@ func runCleanWithItems(ctx context.Context, w io.Writer, force bool, items []str totalFailed := len(result.FailedBranches) + len(result.FailedStates) + len(result.FailedCheckpoints) + len(failedTempFiles) if totalDeleted > 0 { - fmt.Fprintf(w, "Deleted %d items:\n", totalDeleted) + fmt.Fprintf(w, "✓ Deleted %d %s:\n", totalDeleted, itemWord(totalDeleted)) if len(result.ShadowBranches) > 0 { - fmt.Fprintf(w, "\n Shadow branches (%d):\n", len(result.ShadowBranches)) + fmt.Fprintf(w, "\nShadow branches (%d):\n", len(result.ShadowBranches)) for _, branch := range result.ShadowBranches { - fmt.Fprintf(w, " %s\n", branch) + fmt.Fprintf(w, " %s\n", branch) } } if len(result.SessionStates) > 0 { - fmt.Fprintf(w, "\n Session states (%d):\n", len(result.SessionStates)) + fmt.Fprintf(w, "\nSession states (%d):\n", len(result.SessionStates)) for _, state := range result.SessionStates { - fmt.Fprintf(w, " %s\n", state) + fmt.Fprintf(w, " %s\n", state) } } if len(result.Checkpoints) > 0 { - fmt.Fprintf(w, "\n Checkpoints (%d):\n", len(result.Checkpoints)) + fmt.Fprintf(w, "\nCheckpoints (%d):\n", len(result.Checkpoints)) for _, cp := range result.Checkpoints { - fmt.Fprintf(w, " %s\n", cp) + fmt.Fprintf(w, " %s\n", cp) } } if len(deletedTempFiles) > 0 { - fmt.Fprintf(w, "\n Temp files (%d):\n", len(deletedTempFiles)) + fmt.Fprintf(w, "\nTemp files (%d):\n", len(deletedTempFiles)) for _, file := range deletedTempFiles { - fmt.Fprintf(w, " %s\n", file) + fmt.Fprintf(w, " %s\n", file) } } } if totalFailed > 0 { - fmt.Fprintf(w, "\nFailed to delete %d items:\n", totalFailed) + fmt.Fprintf(w, "\nFailed to delete %d %s:\n", totalFailed, itemWord(totalFailed)) if len(result.FailedBranches) > 0 { - fmt.Fprintf(w, "\n Shadow branches:\n") + fmt.Fprintf(w, "\nShadow branches:\n") for _, branch := range result.FailedBranches { - fmt.Fprintf(w, " %s\n", branch) + fmt.Fprintf(w, " %s\n", branch) } } if len(result.FailedStates) > 0 { - fmt.Fprintf(w, "\n Session states:\n") + fmt.Fprintf(w, "\nSession states:\n") for _, state := range result.FailedStates { - fmt.Fprintf(w, " %s\n", state) + fmt.Fprintf(w, " %s\n", state) } } if len(result.FailedCheckpoints) > 0 { - fmt.Fprintf(w, "\n Checkpoints:\n") + fmt.Fprintf(w, "\nCheckpoints:\n") for _, cp := range result.FailedCheckpoints { - fmt.Fprintf(w, " %s\n", cp) + fmt.Fprintf(w, " %s\n", cp) } } if len(failedTempFiles) > 0 { - fmt.Fprintf(w, "\n Temp files:\n") + fmt.Fprintf(w, "\nTemp files:\n") for _, fe := range failedTempFiles { - fmt.Fprintf(w, " %s: %v\n", fe.File, fe.Err) + fmt.Fprintf(w, " %s: %v\n", fe.File, fe.Err) } } - return fmt.Errorf("failed to delete %d items", totalFailed) + return fmt.Errorf("failed to delete %d %s", totalFailed, itemWord(totalFailed)) } return nil } + +// itemWord returns "item" or "items" based on count. +func itemWord(n int) string { + if n == 1 { + return "item" + } + return "items" +} diff --git a/cmd/entire/cli/clean_test.go b/cmd/entire/cli/clean_test.go index af739e613..9f703a0f0 100644 --- a/cmd/entire/cli/clean_test.go +++ b/cmd/entire/cli/clean_test.go @@ -12,8 +12,19 @@ import ( "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" + "github.com/spf13/cobra" ) +// newTestCleanCmd creates a cobra.Command with captured stdout for testing runClean. +func newTestCleanCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + return cmd, &stdout +} + func setupCleanTestRepo(t *testing.T) (*git.Repository, plumbing.Hash) { t.Helper() @@ -70,9 +81,9 @@ func TestRunClean_NoOrphanedItems(t *testing.T) { setupCleanTestRepo(t) var stdout bytes.Buffer - err := runClean(context.Background(), &stdout, false) + err := runCleanWithItems(context.Background(), &stdout, false, []strategy.CleanupItem{}, nil) if err != nil { - t.Fatalf("runClean() error = %v", err) + t.Fatalf("runCleanWithItems() error = %v", err) } output := stdout.String() @@ -99,17 +110,24 @@ func TestRunClean_PreviewMode(t *testing.T) { t.Fatalf("failed to create %s: %v", paths.MetadataBranchName, err) } + // Use runClean with force=true would need TTY for confirmation, + // so test preview output via runCleanWithItems directly. + items, err := strategy.ListAllCleanupItems(context.Background()) + if err != nil { + t.Fatalf("ListAllCleanupItems() error = %v", err) + } + var stdout bytes.Buffer - err := runClean(context.Background(), &stdout, false) // force=false + err = runCleanWithItems(context.Background(), &stdout, false, items, nil) if err != nil { - t.Fatalf("runClean() error = %v", err) + t.Fatalf("runCleanWithItems() error = %v", err) } output := stdout.String() // Should show preview header - if !strings.Contains(output, "items to clean") { - t.Errorf("Expected 'items to clean' in output, got: %s", output) + if !strings.Contains(output, "to clean") { + t.Errorf("Expected 'to clean' in output, got: %s", output) } // Should list the shadow branches @@ -151,17 +169,17 @@ func TestRunClean_ForceMode(t *testing.T) { } } - var stdout bytes.Buffer - err := runClean(context.Background(), &stdout, true) // force=true + cmd, stdout := newTestCleanCmd(t) + err := runClean(cmd.Context(), cmd, true) // force=true skips confirmation if err != nil { t.Fatalf("runClean() error = %v", err) } output := stdout.String() - // Should show deletion confirmation - if !strings.Contains(output, "Deleted") { - t.Errorf("Expected 'Deleted' in output, got: %s", output) + // Should show deletion confirmation with ✓ prefix + if !strings.Contains(output, "✓ Deleted") { + t.Errorf("Expected '✓ Deleted' in output, got: %s", output) } // Branches should be deleted @@ -187,8 +205,8 @@ func TestRunClean_SessionsBranchPreserved(t *testing.T) { t.Fatalf("failed to create entire/checkpoints/v1: %v", err) } - var stdout bytes.Buffer - err := runClean(context.Background(), &stdout, true) // force=true + cmd, _ := newTestCleanCmd(t) + err := runClean(cmd.Context(), cmd, true) // force=true if err != nil { t.Fatalf("runClean() error = %v", err) } @@ -211,8 +229,8 @@ func TestRunClean_NotGitRepository(t *testing.T) { t.Chdir(dir) paths.ClearWorktreeRootCache() - var stdout bytes.Buffer - err := runClean(context.Background(), &stdout, false) + cmd, _ := newTestCleanCmd(t) + err := runClean(cmd.Context(), cmd, true) // force=true to skip TTY prompt // Should return error for non-git directory if err == nil { @@ -243,10 +261,16 @@ func TestRunClean_Subdirectory(t *testing.T) { t.Chdir(subDir) paths.ClearWorktreeRootCache() + // Use ListAllCleanupItems + runCleanWithItems to test preview without TTY + items, err := strategy.ListAllCleanupItems(context.Background()) + if err != nil { + t.Fatalf("ListAllCleanupItems() from subdirectory error = %v", err) + } + var stdout bytes.Buffer - err = runClean(context.Background(), &stdout, false) + err = runCleanWithItems(context.Background(), &stdout, false, items, nil) if err != nil { - t.Fatalf("runClean() from subdirectory error = %v", err) + t.Fatalf("runCleanWithItems() from subdirectory error = %v", err) } output := stdout.String() @@ -282,20 +306,24 @@ func TestRunCleanWithItems_PartialFailure(t *testing.T) { t.Fatal("runCleanWithItems() should return error when items fail to delete") } - // Error message should indicate the failure - if !strings.Contains(err.Error(), "failed to delete") { - t.Errorf("Error should mention 'failed to delete', got: %v", err) + // Error message should indicate the failure with correct grammar + if !strings.Contains(err.Error(), "failed to delete 1 item") { + t.Errorf("Error should mention 'failed to delete 1 item', got: %v", err) + } + // Verify singular (not "1 items") + if strings.Contains(err.Error(), "1 items") { + t.Errorf("Error should use singular 'item' for count 1, got: %v", err) } - // Output should show the successful deletion + // Output should show the successful deletion with ✓ and singular grammar output := stdout.String() - if !strings.Contains(output, "Deleted 1 items") { - t.Errorf("Output should show successful deletion, got: %s", output) + if !strings.Contains(output, "✓ Deleted 1 item:") { + t.Errorf("Output should show '✓ Deleted 1 item:', got: %s", output) } - // Output should also show the failures - if !strings.Contains(output, "Failed to delete 1 items") { - t.Errorf("Output should show failures, got: %s", output) + // Output should also show the failure with singular grammar + if !strings.Contains(output, "Failed to delete 1 item:") { + t.Errorf("Output should show 'Failed to delete 1 item:', got: %s", output) } } @@ -318,20 +346,20 @@ func TestRunCleanWithItems_AllFailures(t *testing.T) { t.Fatal("runCleanWithItems() should return error when items fail to delete") } - // Error message should indicate 2 failures + // Error message should indicate 2 failures with plural grammar if !strings.Contains(err.Error(), "failed to delete 2 items") { t.Errorf("Error should mention 'failed to delete 2 items', got: %v", err) } - // Output should NOT show any successful deletions + // Output should NOT show any successful deletions (no ✓ Deleted line) output := stdout.String() - if strings.Contains(output, "Deleted") { + if strings.Contains(output, "✓ Deleted") { t.Errorf("Output should not show successful deletions, got: %s", output) } - // Output should show the failures - if !strings.Contains(output, "Failed to delete 2 items") { - t.Errorf("Output should show failures, got: %s", output) + // Output should show the failures with plural grammar + if !strings.Contains(output, "Failed to delete 2 items:") { + t.Errorf("Output should show 'Failed to delete 2 items:', got: %s", output) } } diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 632c78af2..b9e6fc008 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -65,10 +65,13 @@ type stuckSession struct { } func runSessionsFix(cmd *cobra.Command, force bool) error { + var finalErr error + // Check 1: Disconnected metadata branches metadataErr := checkDisconnectedMetadata(cmd, force) if metadataErr != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Error: metadata check failed: %v\n", metadataErr) + finalErr = NewSilentError(fmt.Errorf("metadata check failed: %w", metadataErr)) } fmt.Fprintln(cmd.OutOrStdout()) @@ -82,8 +85,8 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { if len(states) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No stuck sessions found.") - if metadataErr != nil { - return fmt.Errorf("metadata check failed: %w", metadataErr) + if finalErr != nil { + return finalErr } return nil } @@ -107,8 +110,8 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { if len(stuck) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No stuck sessions found.") - if metadataErr != nil { - return fmt.Errorf("metadata check failed: %w", metadataErr) + if finalErr != nil { + return finalErr } return nil } @@ -126,14 +129,14 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { if err := strat.CondenseSessionByID(ctx, ss.State.SessionID); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to condense session %s: %v\n", ss.State.SessionID, err) } else { - fmt.Fprintf(cmd.OutOrStdout(), " -> Condensed session %s\n\n", ss.State.SessionID) + fmt.Fprintf(cmd.OutOrStdout(), " ✓ Condensed session %s\n\n", ss.State.SessionID) } } else { // Discard if we can't condense if err := discardSession(ctx, ss, repo, cmd.ErrOrStderr()); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to discard session %s: %v\n", ss.State.SessionID, err) } else { - fmt.Fprintf(cmd.OutOrStdout(), " -> Discarded session %s\n\n", ss.State.SessionID) + fmt.Fprintf(cmd.OutOrStdout(), " ✓ Discarded session %s\n\n", ss.State.SessionID) } } continue @@ -153,21 +156,21 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { if err := strat.CondenseSessionByID(ctx, ss.State.SessionID); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to condense session %s: %v\n", ss.State.SessionID, err) } else { - fmt.Fprintf(cmd.OutOrStdout(), " -> Condensed session %s\n\n", ss.State.SessionID) + fmt.Fprintf(cmd.OutOrStdout(), " ✓ Condensed session %s\n\n", ss.State.SessionID) } case "discard": if err := discardSession(ctx, ss, repo, cmd.ErrOrStderr()); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to discard session %s: %v\n", ss.State.SessionID, err) } else { - fmt.Fprintf(cmd.OutOrStdout(), " -> Discarded session %s\n\n", ss.State.SessionID) + fmt.Fprintf(cmd.OutOrStdout(), " ✓ Discarded session %s\n\n", ss.State.SessionID) } case "skip": fmt.Fprintf(cmd.OutOrStdout(), " -> Skipped\n\n") } } - if metadataErr != nil { - return fmt.Errorf("metadata check failed: %w", metadataErr) + if finalErr != nil { + return finalErr } return nil @@ -320,7 +323,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { w := cmd.OutOrStdout() if !disconnected { - fmt.Fprintln(w, "Metadata branches: OK") + fmt.Fprintln(w, "✓ Metadata branches: OK") return nil } @@ -354,7 +357,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { return fmt.Errorf("failed to reconcile metadata branches: %w", fixErr) } - fmt.Fprintln(w, " -> Fixed: metadata branches reconciled") + fmt.Fprintln(w, " ✓ Fixed: metadata branches reconciled") return nil } diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index f50063f69..7d2d73167 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "strings" "testing" "time" @@ -292,8 +293,8 @@ func TestClassifySession_WorktreeIDInShadowBranch(t *testing.T) { } // TestRunSessionsFix_MetadataCheckFailure_PropagatesError verifies that when -// checkDisconnectedMetadata fails, runSessionsFix returns a non-nil error -// instead of silently swallowing it. +// checkDisconnectedMetadata fails, runSessionsFix returns a SilentError so the +// custom stderr message is not printed twice by main.go. func TestRunSessionsFix_MetadataCheckFailure_PropagatesError(t *testing.T) { // Cannot use t.Parallel() because t.Chdir modifies process-global state. dir := setupGitRepoForPhaseTest(t) @@ -341,8 +342,46 @@ func TestRunSessionsFix_MetadataCheckFailure_PropagatesError(t *testing.T) { err = runSessionsFix(cmd, true) - // The metadata check error should be propagated, not swallowed + // The metadata check error should be propagated, not swallowed. + // It should be SilentError because the user-facing message was already printed. require.Error(t, err, "runSessionsFix should return error when metadata check fails") + var silentErr *SilentError + require.ErrorAs(t, err, &silentErr) assert.Contains(t, err.Error(), "metadata check failed") assert.Contains(t, stderr.String(), "Error: metadata check failed") } + +func TestRunSessionsFix_ForceDiscardOutput_Indented(t *testing.T) { + // Cannot use t.Parallel() because t.Chdir modifies process-global state. + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + state := &strategy.SessionState{ + SessionID: "2026-02-02-doctor-output", + BaseCommit: testBaseCommit, + Phase: session.PhaseActive, + StartedAt: time.Now().Add(-2 * time.Hour), + } + require.NoError(t, strategy.SaveSessionState(context.Background(), state)) + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + require.NoError(t, runSessionsFix(cmd, true)) + assert.Empty(t, stderr.String()) + + output := stdout.String() + assert.Contains(t, output, "✓ Metadata branches: OK") + assert.Contains(t, output, "Found 1 stuck session(s):") + assert.Contains(t, output, " Session: 2026-02-02-doctor-output") + assert.Contains(t, output, " ✓ Discarded session 2026-02-02-doctor-output") + + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Discarded session") { + assert.True(t, strings.HasPrefix(line, " ✓ "), "expected nested success line to stay indented: %q", line) + } + } +} diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index 08e519e60..fa2d7b482 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -68,8 +68,8 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { } // Verify output contains session info and resume command - if !strings.Contains(output, "Session:") { - t.Errorf("output should contain 'Session:', got: %s", output) + if !strings.Contains(output, "Restored session") { + t.Errorf("output should contain 'Restored session', got: %s", output) } if !strings.Contains(output, "claude -r") { t.Errorf("output should contain 'claude -r', got: %s", output) @@ -118,8 +118,8 @@ func TestResume_AlreadyOnBranch(t *testing.T) { } // Should still show session info - if !strings.Contains(output, "Session:") { - t.Errorf("output should contain 'Session:', got: %s", output) + if !strings.Contains(output, "Restored session") { + t.Errorf("output should contain 'Restored session', got: %s", output) } } @@ -334,7 +334,7 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { } // Should show session info (multi-session output says "Restored N sessions") - if !strings.Contains(output, "Restored 2 sessions") && !strings.Contains(output, "Session:") { + if !strings.Contains(output, "Restored 2 sessions") && !strings.Contains(output, "Restored session") { t.Errorf("output should contain session info, got: %s", output) } @@ -399,8 +399,8 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { // Should NOT show session info since metadata is missing // The resume command should silently skip commits without valid metadata - if strings.Contains(output, "Session:") { - t.Errorf("output should not contain 'Session:' when metadata is missing, got: %s", output) + if strings.Contains(output, "Restored session") { + t.Errorf("output should not contain 'Restored session' when metadata is missing, got: %s", output) } } @@ -467,8 +467,8 @@ func TestResume_AfterMergingMain(t *testing.T) { } // Should find the session from the older commit (before the merge) - if !strings.Contains(output, "Session:") { - t.Errorf("output should contain 'Session:', got: %s", output) + if !strings.Contains(output, "Restored session") { + t.Errorf("output should contain 'Restored session', got: %s", output) } if !strings.Contains(output, "claude -r") { t.Errorf("output should contain 'claude -r', got: %s", output) @@ -1287,7 +1287,7 @@ func TestResume_RelocatedRepo(t *testing.T) { } // Verify output contains session info - if !strings.Contains(output, "Session:") { - t.Errorf("output should contain 'Session:', got: %s", output) + if !strings.Contains(output, "Restored session") { + t.Errorf("output should contain 'Restored session', got: %s", output) } } diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index f6217ccf0..936c09787 100644 --- a/cmd/entire/cli/reset.go +++ b/cmd/entire/cli/reset.go @@ -82,19 +82,17 @@ Without --force, prompts for confirmation before deleting.`, ) if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return nil - } - return fmt.Errorf("failed to get confirmation: %w", err) + return handleFormCancellation(cmd.OutOrStdout(), "Reset", err) } if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Reset cancelled.") return nil } } // Call strategy's Reset method - if err := strat.Reset(ctx); err != nil { + if err := strat.Reset(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr()); err != nil { return fmt.Errorf("reset failed: %w", err) } @@ -135,22 +133,20 @@ func runResetSession(ctx context.Context, cmd *cobra.Command, strat *strategy.Ma ) if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return nil - } - return fmt.Errorf("failed to get confirmation: %w", err) + return handleFormCancellation(cmd.OutOrStdout(), "Reset", err) } if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Reset cancelled.") return nil } } - if err := strat.ResetSession(ctx, sessionID); err != nil { + if err := strat.ResetSession(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), sessionID); err != nil { return fmt.Errorf("reset session failed: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Session %s has been reset. File changes remain in the working directory.\n", sessionID) + fmt.Fprintf(cmd.OutOrStdout(), "✓ Session %s has been reset. File changes remain in the working directory.\n", sessionID) return nil } diff --git a/cmd/entire/cli/reset_test.go b/cmd/entire/cli/reset_test.go index f384741cf..75c5cdc76 100644 --- a/cmd/entire/cli/reset_test.go +++ b/cmd/entire/cli/reset_test.go @@ -143,6 +143,10 @@ func TestResetCmd_WithForce(t *testing.T) { t.Fatalf("reset command error = %v", err) } + if output := stdout.String(); !strings.Contains(output, "✓ Deleted shadow branch") { + t.Fatalf("expected reset success output, got: %q", output) + } + // Verify shadow branch deleted refName := plumbing.NewBranchReferenceName(shadowBranch) if _, err := repo.Reference(refName, true); err == nil { diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 7d23497b7..a78debeb1 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -49,7 +50,7 @@ most recent commit with a checkpoint. You'll be prompted to confirm resuming in if checkDisabledGuard(cmd.Context(), cmd.OutOrStdout()) { return nil } - return runResume(cmd.Context(), args[0], force) + return runResume(cmd.Context(), cmd, args[0], force) }, } @@ -58,12 +59,15 @@ most recent commit with a checkpoint. You'll be prompted to confirm resuming in return cmd } -func runResume(ctx context.Context, branchName string, force bool) error { +func runResume(ctx context.Context, cmd *cobra.Command, branchName string, force bool) error { + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + // Check if we're already on this branch currentBranch, err := GetCurrentBranch(ctx) if err == nil && currentBranch == branchName { // Already on the branch, skip checkout - return resumeFromCurrentBranch(ctx, branchName, force) + return resumeFromCurrentBranch(ctx, w, errW, branchName, force) } // Check if branch exists locally @@ -93,12 +97,12 @@ func runResume(ctx context.Context, branchName string, force bool) error { } // Fetch and checkout the remote branch - fmt.Fprintf(os.Stderr, "Fetching branch '%s' from origin...\n", branchName) + fmt.Fprintf(w, "Fetching branch '%s' from origin...\n", branchName) if err := FetchAndCheckoutRemoteBranch(ctx, branchName); err != nil { - fmt.Fprintf(os.Stderr, "Failed to checkout branch: %v\n", err) + fmt.Fprintf(errW, "Error: failed to checkout branch: %v\n", err) return NewSilentError(errors.New("failed to checkout branch")) } - fmt.Fprintf(os.Stderr, "Switched to branch '%s'\n", branchName) + fmt.Fprintf(w, "✓ Switched to branch %s\n", branchName) } else { // Branch exists locally, check for uncommitted changes before checkout hasChanges, err := HasUncommittedChanges(ctx) @@ -111,16 +115,16 @@ func runResume(ctx context.Context, branchName string, force bool) error { // Checkout the branch if err := CheckoutBranch(ctx, branchName); err != nil { - fmt.Fprintf(os.Stderr, "Failed to checkout branch: %v\n", err) + fmt.Fprintf(errW, "Error: failed to checkout branch: %v\n", err) return NewSilentError(errors.New("failed to checkout branch")) } - fmt.Fprintf(os.Stderr, "Switched to branch '%s'\n", branchName) + fmt.Fprintf(w, "✓ Switched to branch %s\n", branchName) } - return resumeFromCurrentBranch(ctx, branchName, force) + return resumeFromCurrentBranch(ctx, w, errW, branchName, force) } -func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) error { +func resumeFromCurrentBranch(ctx context.Context, w, errW io.Writer, branchName string, force bool) error { repo, err := openRepository(ctx) if err != nil { return fmt.Errorf("not a git repository: %w", err) @@ -132,23 +136,23 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) return err } if len(result.checkpointIDs) == 0 { - fmt.Fprintf(os.Stderr, "No Entire checkpoint found on branch '%s'\n", branchName) + fmt.Fprintf(w, "No Entire checkpoint found on branch '%s'\n", branchName) return nil } // If there are newer commits without checkpoints, ask for confirmation. // Merge commits (e.g., from merging main) don't count as "work" and are skipped silently. if result.newerCommitsExist && !force { - fmt.Fprintf(os.Stderr, "Found checkpoint in an older commit.\n") - fmt.Fprintf(os.Stderr, "There are %d newer commit(s) on this branch without checkpoints.\n", result.newerCommitCount) - fmt.Fprintf(os.Stderr, "Checkpoint from: %s %s\n\n", result.commitHash[:7], firstLine(result.commitMessage)) + fmt.Fprintf(w, "Found checkpoint in an older commit.\n") + fmt.Fprintf(w, "There are %d newer commit(s) on this branch without checkpoints.\n", result.newerCommitCount) + fmt.Fprintf(w, "Checkpoint from: %s %s\n\n", result.commitHash[:7], firstLine(result.commitMessage)) shouldResume, err := promptResumeFromOlderCheckpoint() if err != nil { return err } if !shouldResume { - fmt.Fprintf(os.Stderr, "Resume cancelled.\n") + fmt.Fprintf(w, "Resume cancelled.\n") return nil } } @@ -163,12 +167,12 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) latest, tree, err := resolveLatestCheckpoint(ctx, repo, result.checkpointIDs) if err != nil { // No metadata available — nothing to resume from - fmt.Fprintf(os.Stderr, "Found %d checkpoints for commit %s but metadata is not available\n", + fmt.Fprintf(w, "Found %d checkpoints for commit %s but metadata is not available\n", len(result.checkpointIDs), result.commitHash[:7]) - return checkRemoteMetadata(ctx, repo, result.checkpointIDs[0]) + return checkRemoteMetadata(ctx, w, errW, repo, result.checkpointIDs[0]) } skipped := len(result.checkpointIDs) - 1 - fmt.Fprintf(os.Stderr, "Found %d checkpoints for commit %s, resuming from the latest (%d older checkpoints skipped)\n", + fmt.Fprintf(w, "Found %d checkpoints for commit %s, resuming from the latest (%d older checkpoints skipped)\n", len(result.checkpointIDs), result.commitHash[:7], skipped) checkpointID = latest metadataTree = tree @@ -180,7 +184,7 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) metadataTree, err = strategy.GetMetadataBranchTree(repo) if err != nil { // No local metadata branch, check if remote has it - return checkRemoteMetadata(ctx, repo, checkpointID) + return checkRemoteMetadata(ctx, w, errW, repo, checkpointID) } } @@ -188,10 +192,10 @@ func resumeFromCurrentBranch(ctx context.Context, branchName string, force bool) metadata, err := strategy.ReadCheckpointMetadata(metadataTree, checkpointID.Path()) if err != nil { // Checkpoint exists in commit but no local metadata - check remote - return checkRemoteMetadata(ctx, repo, checkpointID) + return checkRemoteMetadata(ctx, w, errW, repo, checkpointID) } - return resumeSession(ctx, metadata, force) + return resumeSession(ctx, w, errW, metadata, force) } // resolveLatestCheckpoint reads metadata for each checkpoint ID and returns @@ -395,32 +399,32 @@ func promptResumeFromOlderCheckpoint() (bool, error) { // checkRemoteMetadata checks if checkpoint metadata exists on origin/entire/checkpoints/v1 // and automatically fetches it if available. -func checkRemoteMetadata(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID) error { +func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, repo *git.Repository, checkpointID id.CheckpointID) error { // Try to get remote metadata branch tree remoteTree, err := strategy.GetRemoteMetadataBranchTree(repo) if err != nil { - fmt.Fprintf(os.Stderr, "Checkpoint '%s' found in commit but session metadata not available\n", checkpointID) - fmt.Fprintf(os.Stderr, "The entire/checkpoints/v1 branch may not exist locally or on the remote.\n") + fmt.Fprintf(w, "Checkpoint '%s' found in commit but session metadata not available\n", checkpointID) + fmt.Fprintf(w, "The entire/checkpoints/v1 branch may not exist locally or on the remote.\n") return nil //nolint:nilerr // Informational message, not a fatal error } // Check if the checkpoint exists on the remote metadata, err := strategy.ReadCheckpointMetadata(remoteTree, checkpointID.Path()) if err != nil { - fmt.Fprintf(os.Stderr, "Checkpoint '%s' found in commit but session metadata not available\n", checkpointID) + fmt.Fprintf(w, "Checkpoint '%s' found in commit but session metadata not available\n", checkpointID) return nil //nolint:nilerr // Informational message, not a fatal error } // Metadata exists on remote but not locally - fetch it automatically - fmt.Fprintf(os.Stderr, "Fetching session metadata from origin...\n") + fmt.Fprintf(w, "Fetching session metadata from origin...\n") if err := FetchMetadataBranch(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Failed to fetch metadata: %v\n", err) - fmt.Fprintf(os.Stderr, "You can try manually: git fetch origin entire/checkpoints/v1:entire/checkpoints/v1\n") + fmt.Fprintf(errW, "Error: failed to fetch metadata: %v\n", err) + fmt.Fprintf(errW, "You can try manually: git fetch origin entire/checkpoints/v1:entire/checkpoints/v1\n") return NewSilentError(errors.New("failed to fetch metadata")) } // Now resume the session with the fetched metadata - return resumeSession(ctx, metadata, false) + return resumeSession(ctx, w, errW, metadata, false) } // resumeSession restores and displays the resume command for a specific session. @@ -428,7 +432,7 @@ func checkRemoteMetadata(ctx context.Context, repo *git.Repository, checkpointID // If force is false, prompts for confirmation when local logs have newer timestamps. // The caller must provide the already-resolved checkpoint metadata to avoid redundant lookups // and to support both local and remote metadata trees. -func resumeSession(ctx context.Context, metadata *strategy.CheckpointInfo, force bool) error { +func resumeSession(ctx context.Context, w, errW io.Writer, metadata *strategy.CheckpointInfo, force bool) error { checkpointID := metadata.CheckpointID sessionID := metadata.SessionID @@ -473,10 +477,10 @@ func resumeSession(ctx context.Context, metadata *strategy.CheckpointInfo, force Agent: metadata.Agent, } - sessions, restoreErr := strat.RestoreLogsOnly(ctx, point, force) + sessions, restoreErr := strat.RestoreLogsOnly(ctx, w, errW, point, force) if restoreErr != nil || len(sessions) == 0 { // Fall back to single-session restore (e.g., old checkpoints without agent metadata) - return resumeSingleSession(ctx, ag, sessionID, checkpointID, repoRoot, force) + return resumeSingleSession(ctx, w, errW, ag, sessionID, checkpointID, repoRoot, force) } logging.Debug(logCtx, "resume session completed", @@ -484,40 +488,29 @@ func resumeSession(ctx context.Context, metadata *strategy.CheckpointInfo, force slog.Int("session_count", len(sessions)), ) - return displayRestoredSessions(sessions) + return displayRestoredSessions(w, sessions) } // displayRestoredSessions sorts sessions by CreatedAt and prints resume commands. -func displayRestoredSessions(sessions []strategy.RestoredSession) error { +func displayRestoredSessions(w io.Writer, sessions []strategy.RestoredSession) error { sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) }) if len(sessions) > 1 { - fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(sessions)) + fmt.Fprintf(w, "\n✓ Restored %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") + fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID) + fmt.Fprintf(w, "\nTo continue this session, run:\n") } + isMulti := len(sessions) > 1 for i, sess := range sessions { sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent) if err != nil { return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, err) } - cmd := sessionAgent.FormatResumeCommand(sess.SessionID) - - 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) - } + printSessionCommand(w, sessionAgent.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1) } return nil @@ -526,7 +519,7 @@ func displayRestoredSessions(sessions []strategy.RestoredSession) error { // resumeSingleSession restores a single session (fallback when multi-session restore fails). // Always overwrites existing session logs to ensure consistency with checkpoint state. // If force is false, prompts for confirmation when local log has newer timestamps. -func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, repoRoot string, force bool) error { +func resumeSingleSession(ctx context.Context, w, errW io.Writer, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, repoRoot string, force bool) error { sessionLogPath, err := resolveTranscriptPath(ctx, sessionID, ag) if err != nil { return fmt.Errorf("failed to resolve transcript path: %w", err) @@ -536,9 +529,9 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, logging.Debug(ctx, "resume session: empty checkpoint ID", slog.String("checkpoint_id", checkpointID.String()), ) - fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID) + fmt.Fprintf(w, "\nTo continue this session, run:\n") + fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID)) return nil } @@ -549,9 +542,9 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, slog.String("checkpoint_id", checkpointID.String()), slog.String("session_id", sessionID), ) - fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "Session '%s' found in commit trailer but session log not available\n", sessionID) + fmt.Fprintf(w, "\nTo continue this session, run:\n") + fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID)) return nil } logging.Error(ctx, "resume session failed", @@ -575,12 +568,12 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, LocalTime: localTime, CheckpointTime: checkpointTime, }} - shouldOverwrite, promptErr := strategy.PromptOverwriteNewerLogs(sessions) + shouldOverwrite, promptErr := strategy.PromptOverwriteNewerLogs(errW, sessions) if promptErr != nil { return fmt.Errorf("failed to get confirmation: %w", promptErr) } if !shouldOverwrite { - fmt.Fprintf(os.Stderr, "Resume cancelled. Local session log preserved.\n") + fmt.Fprintf(w, "Resume cancelled. Local session log preserved.\n") return nil } } @@ -614,10 +607,10 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, slog.String("session_id", sessionID), ) - fmt.Fprintf(os.Stderr, "Session restored to: %s\n", sessionLogPath) - fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "✓ Session restored to: %s\n", sessionLogPath) + fmt.Fprintf(w, " Session: %s\n", sessionID) + fmt.Fprintf(w, "\nTo continue this session, run:\n") + fmt.Fprintf(w, " %s\n", ag.FormatResumeCommand(sessionID)) return nil } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index e0177d127..570130f11 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -1,9 +1,11 @@ package cli import ( + "bytes" "context" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -18,6 +20,7 @@ import ( "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/filemode" "github.com/go-git/go-git/v6/plumbing/object" + "github.com/spf13/cobra" ) func TestFirstLine(t *testing.T) { @@ -205,7 +208,7 @@ func TestResumeFromCurrentBranch_NoCheckpoint(t *testing.T) { setupResumeTestRepo(t, tmpDir, false) // Run resumeFromCurrentBranch - should not error, just report no checkpoint found - err := resumeFromCurrentBranch(context.Background(), "master", false) + err := resumeFromCurrentBranch(context.Background(), io.Discard, io.Discard, "master", false) if err != nil { t.Errorf("resumeFromCurrentBranch() returned error for commit without checkpoint: %v", err) } @@ -229,7 +232,11 @@ func TestRunResume_AlreadyOnBranch(t *testing.T) { } // Run resume on the branch we're already on - should skip checkout - err := runResume(context.Background(), "feature", false) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := runResume(context.Background(), cmd, "feature", false) // Should not error (no session, but shouldn't error) if err != nil { t.Errorf("runResume() returned error when already on branch: %v", err) @@ -243,7 +250,11 @@ func TestRunResume_BranchDoesNotExist(t *testing.T) { setupResumeTestRepo(t, tmpDir, false) // Run resume on a branch that doesn't exist - err := runResume(context.Background(), "nonexistent", false) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := runResume(context.Background(), cmd, "nonexistent", false) if err == nil { t.Error("runResume() expected error for nonexistent branch, got nil") } @@ -262,7 +273,11 @@ func TestRunResume_UncommittedChanges(t *testing.T) { } // Run resume - should fail due to uncommitted changes - err := runResume(context.Background(), "feature", false) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := runResume(context.Background(), cmd, "feature", false) if err == nil { t.Error("runResume() expected error for uncommitted changes, got nil") } @@ -617,7 +632,7 @@ func TestCheckRemoteMetadata_MetadataExistsOnRemote(t *testing.T) { // Call checkRemoteMetadata - should find it on remote and attempt to fetch // In this test environment without a real origin remote, the fetch will fail // but it should return a SilentError (user-friendly error message already printed) - err = checkRemoteMetadata(context.Background(), repo, checkpointID) + err = checkRemoteMetadata(context.Background(), io.Discard, io.Discard, repo, checkpointID) if err == nil { t.Error("checkRemoteMetadata() should return SilentError when fetch fails") } else { @@ -642,7 +657,7 @@ func TestCheckRemoteMetadata_NoRemoteMetadataBranch(t *testing.T) { // Don't create any remote ref - simulating no remote entire/checkpoints/v1 // Call checkRemoteMetadata - should handle gracefully (no remote branch) - err := checkRemoteMetadata(context.Background(), repo, "nonexistent123") + err := checkRemoteMetadata(context.Background(), io.Discard, io.Discard, repo, "nonexistent123") if err != nil { t.Errorf("checkRemoteMetadata() returned error when no remote branch: %v", err) } @@ -677,7 +692,7 @@ func TestCheckRemoteMetadata_CheckpointNotOnRemote(t *testing.T) { } // Call checkRemoteMetadata with a DIFFERENT checkpoint ID (not on remote) - err = checkRemoteMetadata(context.Background(), repo, "abcd12345678") + err = checkRemoteMetadata(context.Background(), io.Discard, io.Discard, repo, "abcd12345678") if err != nil { t.Errorf("checkRemoteMetadata() returned error for missing checkpoint: %v", err) } @@ -738,7 +753,7 @@ func TestResumeFromCurrentBranch_FallsBackToRemote(t *testing.T) { // Run resumeFromCurrentBranch - should fall back to remote and attempt fetch // In this test environment without a real origin remote, the fetch will fail // but it should return a SilentError (user-friendly error message already printed) - err = resumeFromCurrentBranch(context.Background(), "master", false) + err = resumeFromCurrentBranch(context.Background(), io.Discard, io.Discard, "master", false) if err == nil { t.Error("resumeFromCurrentBranch() should return SilentError when fetch fails") } else { @@ -748,3 +763,114 @@ func TestResumeFromCurrentBranch_FallsBackToRemote(t *testing.T) { } } } + +func TestDisplayRestoredSessions_SingleSessionOutput(t *testing.T) { + t.Parallel() + + session := strategy.RestoredSession{ + SessionID: "2026-02-02-resume-output", + Agent: "Claude Code", + Prompt: "Implement auth", + CreatedAt: time.Date(2026, time.February, 2, 12, 0, 0, 0, time.UTC), + } + + ag, err := strategy.ResolveAgentForRewind(session.Agent) + if err != nil { + t.Fatalf("ResolveAgentForRewind() error = %v", err) + } + + var output bytes.Buffer + if err := displayRestoredSessions(&output, []strategy.RestoredSession{session}); err != nil { + t.Fatalf("displayRestoredSessions() error = %v", err) + } + + got := output.String() + if !strings.Contains(got, "✓ Restored session 2026-02-02-resume-output.\n") { + t.Fatalf("displayRestoredSessions() missing session header, got: %q", got) + } + if !strings.Contains(got, "\nTo continue this session, run:\n") { + t.Fatalf("displayRestoredSessions() missing continuation header, got: %q", got) + } + wantCommand := " " + ag.FormatResumeCommand(session.SessionID) + " # Implement auth\n" + if !strings.Contains(got, wantCommand) { + t.Fatalf("displayRestoredSessions() missing command %q in %q", wantCommand, got) + } +} + +func TestPrintMultiSessionResumeCommands_SingleSessionHasCheckmark(t *testing.T) { + t.Parallel() + + sessions := []strategy.RestoredSession{ + { + SessionID: "2026-02-02-rewind-single", + Agent: "Claude Code", + Prompt: "Fix the bug", + }, + } + + ag, err := strategy.ResolveAgentForRewind("Claude Code") + if err != nil { + t.Fatalf("ResolveAgentForRewind() error = %v", err) + } + + var output bytes.Buffer + var errOutput bytes.Buffer + printMultiSessionResumeCommands(&output, &errOutput, sessions) + + got := output.String() + if !strings.Contains(got, "✓ Restored session 2026-02-02-rewind-single.\n") { + t.Fatalf("printMultiSessionResumeCommands() single session missing ✓ header, got: %q", got) + } + if !strings.Contains(got, "\nTo continue this session, run:\n") { + t.Fatalf("printMultiSessionResumeCommands() missing continuation line, got: %q", got) + } + wantCommand := " " + ag.FormatResumeCommand("2026-02-02-rewind-single") + " # Fix the bug\n" + if !strings.Contains(got, wantCommand) { + t.Fatalf("printMultiSessionResumeCommands() missing command %q in %q", wantCommand, got) + } + if errOutput.Len() != 0 { + t.Fatalf("printMultiSessionResumeCommands() unexpected stderr: %q", errOutput.String()) + } +} + +func TestPrintMultiSessionResumeCommands_OutputMatchesResumeStyle(t *testing.T) { + t.Parallel() + + sessions := []strategy.RestoredSession{ + { + SessionID: "2026-02-02-rewind-old", + Agent: "Claude Code", + Prompt: "Old prompt", + }, + { + SessionID: "2026-02-02-rewind-new", + Agent: "Claude Code", + Prompt: "Most recent prompt", + }, + } + + ag, err := strategy.ResolveAgentForRewind("Claude Code") + if err != nil { + t.Fatalf("ResolveAgentForRewind() error = %v", err) + } + + var output bytes.Buffer + var errOutput bytes.Buffer + printMultiSessionResumeCommands(&output, &errOutput, sessions) + + got := output.String() + if !strings.Contains(got, "\n✓ Restored 2 sessions. To continue, run:\n") { + t.Fatalf("printMultiSessionResumeCommands() missing multi-session header, got: %q", got) + } + oldCommand := " " + ag.FormatResumeCommand("2026-02-02-rewind-old") + " # Old prompt\n" + if !strings.Contains(got, oldCommand) { + t.Fatalf("printMultiSessionResumeCommands() missing older command %q in %q", oldCommand, got) + } + newCommand := " " + ag.FormatResumeCommand("2026-02-02-rewind-new") + " # Most recent prompt (most recent)\n" + if !strings.Contains(got, newCommand) { + t.Fatalf("printMultiSessionResumeCommands() missing latest command %q in %q", newCommand, got) + } + if errOutput.Len() != 0 { + t.Fatalf("printMultiSessionResumeCommands() unexpected stderr: %q", errOutput.String()) + } +} diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index fe05241b8..84f7a8ae5 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "os" "os/exec" @@ -61,13 +62,15 @@ your agent's context.`, } ctx := cmd.Context() + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() if listFlag { - return runRewindList(ctx) + return runRewindList(ctx, w) } if toFlag != "" { - return runRewindToWithOptions(ctx, toFlag, logsOnlyFlag, resetFlag) + return runRewindToWithOptions(ctx, w, errW, toFlag, logsOnlyFlag, resetFlag) } - return runRewindInteractive(ctx) + return runRewindInteractive(ctx, w, errW) }, } @@ -79,7 +82,7 @@ your agent's context.`, return cmd } -func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // already present in codebase +func runRewindInteractive(ctx context.Context, w, errW io.Writer) error { //nolint:maintidx // already present in codebase // Get the configured strategy start := GetStrategy(ctx) @@ -89,7 +92,7 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre return fmt.Errorf("failed to check for uncommitted changes: %w", err) } if !canRewind { - fmt.Println(changeMsg) + fmt.Fprintln(w, changeMsg) return nil } @@ -100,8 +103,8 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre } if len(points) == 0 { - fmt.Println("No rewind points found.") - fmt.Println("Rewind points are created automatically when agent sessions end.") + fmt.Fprintln(w, "No rewind points found.") + fmt.Fprintln(w, "Rewind points are created automatically when agent sessions end.") return nil } @@ -158,11 +161,14 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre ) if err := form.Run(); err != nil { - return fmt.Errorf("selection cancelled: %w", err) + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return fmt.Errorf("selection failed: %w", err) } if selectedID == "cancel" { - fmt.Println("Rewind cancelled.") + fmt.Fprintln(w, "Rewind cancelled.") return nil } @@ -189,28 +195,30 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre switch { case selectedPoint.IsLogsOnly: // Committed checkpoint - show sha - fmt.Printf("\nSelected: %s %s\n", shortID, sanitizeForTerminal(selectedPoint.Message)) + fmt.Fprintf(w, "\nSelected: %s %s\n", shortID, sanitizeForTerminal(selectedPoint.Message)) case selectedPoint.IsTaskCheckpoint: // Task checkpoint - no sha - fmt.Printf("\nSelected: [Task] %s\n", sanitizeForTerminal(selectedPoint.Message)) + fmt.Fprintf(w, "\nSelected: [Task] %s\n", sanitizeForTerminal(selectedPoint.Message)) default: // Shadow checkpoint - no sha - fmt.Printf("\nSelected: %s\n", sanitizeForTerminal(selectedPoint.Message)) + fmt.Fprintf(w, "\nSelected: %s\n", sanitizeForTerminal(selectedPoint.Message)) } // Handle logs-only points with a sub-choice menu if selectedPoint.IsLogsOnly { - return handleLogsOnlyRewindInteractive(ctx, start, *selectedPoint, shortID) + return handleLogsOnlyRewindInteractive(ctx, w, errW, start, *selectedPoint, shortID) } // Preview rewind to show warnings about files that will be deleted preview, previewErr := start.PreviewRewind(ctx, *selectedPoint) - if previewErr == nil && preview != nil && len(preview.FilesToDelete) > 0 { - fmt.Fprintf(os.Stderr, "\nWarning: The following untracked files will be DELETED:\n") + if previewErr != nil { + fmt.Fprintf(errW, "Warning: could not preview rewind effects: %v\n", previewErr) + } else if preview != nil && len(preview.FilesToDelete) > 0 { + fmt.Fprintf(errW, "\nWarning: The following untracked files will be DELETED:\n") for _, f := range preview.FilesToDelete { - fmt.Fprintf(os.Stderr, " - %s\n", f) + fmt.Fprintf(errW, " - %s\n", f) } - fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(errW, "\n") } // Confirm rewind @@ -226,11 +234,14 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre ) if err := confirmForm.Run(); err != nil { - return fmt.Errorf("confirmation cancelled: %w", err) + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return fmt.Errorf("confirmation failed: %w", err) } if !confirm { - fmt.Println("Rewind cancelled.") + fmt.Fprintln(w, "Rewind cancelled.") return nil } @@ -251,7 +262,7 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre ) // Perform the rewind using strategy - if err := start.Rewind(ctx, *selectedPoint); err != nil { + if err := start.Rewind(ctx, w, errW, *selectedPoint); err != nil { logging.Error(logCtx, "rewind failed", slog.String("checkpoint_id", selectedPoint.ID), slog.String("error", err.Error()), @@ -271,7 +282,7 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre // For task checkpoint: read checkpoint.json to get UUID and truncate transcript checkpoint, err := start.GetTaskCheckpoint(ctx, *selectedPoint) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to read task checkpoint: %v\n", err) + fmt.Fprintf(errW, "Warning: failed to read task checkpoint: %v\n", err) return nil } @@ -279,10 +290,10 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre if checkpoint.CheckpointUUID != "" { // Truncate transcript at checkpoint UUID - if err := restoreTaskCheckpointTranscript(ctx, start, *selectedPoint, sessionID, checkpoint.CheckpointUUID, agent); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to restore truncated session transcript: %v\n", err) + if err := restoreTaskCheckpointTranscript(ctx, w, start, *selectedPoint, sessionID, checkpoint.CheckpointUUID, agent); err != nil { + fmt.Fprintf(errW, "Warning: failed to restore truncated session transcript: %v\n", err) } else { - fmt.Printf("Rewound to task checkpoint. %s\n", agent.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "✓ Rewound to task checkpoint. %s\n", agent.FormatResumeCommand(sessionID)) } return nil } @@ -320,18 +331,18 @@ func runRewindInteractive(ctx context.Context) error { //nolint:maintidx // alre if !restored { // Fall back to local file - if err := restoreSessionTranscript(ctx, transcriptFile, sessionID, agent); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to restore session transcript: %v\n", err) - fmt.Fprintf(os.Stderr, " Source: %s\n", transcriptFile) - fmt.Fprintf(os.Stderr, " Session ID: %s\n", sessionID) + if err := restoreSessionTranscript(ctx, w, transcriptFile, sessionID, agent); err != nil { + fmt.Fprintf(errW, "Warning: failed to restore session transcript: %v\n", err) + fmt.Fprintf(errW, " Source: %s\n", transcriptFile) + fmt.Fprintf(errW, " Session ID: %s\n", sessionID) } } - fmt.Printf("Rewound to %s. %s\n", shortID, agent.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "✓ Rewound to %s. %s\n", shortID, agent.FormatResumeCommand(sessionID)) return nil } -func runRewindList(ctx context.Context) error { +func runRewindList(ctx context.Context, w io.Writer) error { start := GetStrategy(ctx) points, err := start.GetRewindPoints(ctx, 20) @@ -374,15 +385,15 @@ func runRewindList(ctx context.Context) error { if err != nil { return err //nolint:wrapcheck // already present in codebase } - fmt.Println(string(data)) + fmt.Fprintln(w, string(data)) return nil } -func runRewindToWithOptions(ctx context.Context, commitID string, logsOnly bool, reset bool) error { - return runRewindToInternal(ctx, commitID, logsOnly, reset) +func runRewindToWithOptions(ctx context.Context, w, errW io.Writer, commitID string, logsOnly bool, reset bool) error { + return runRewindToInternal(ctx, w, errW, commitID, logsOnly, reset) } -func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, reset bool) error { +func runRewindToInternal(ctx context.Context, w, errW io.Writer, commitID string, logsOnly bool, reset bool) error { start := GetStrategy(ctx) // Check for uncommitted changes (skip for reset which handles this itself) @@ -418,24 +429,26 @@ func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, re // Handle reset mode (for logs-only points) if reset { - return handleLogsOnlyResetNonInteractive(ctx, start, *selectedPoint) + return handleLogsOnlyResetNonInteractive(ctx, w, errW, start, *selectedPoint) } // Handle logs-only restoration: // 1. For logs-only points, always use logs-only restoration // 2. If --logs-only flag is set, use logs-only restoration even for checkpoint points if selectedPoint.IsLogsOnly || logsOnly { - return handleLogsOnlyRewindNonInteractive(ctx, start, *selectedPoint) + return handleLogsOnlyRewindNonInteractive(ctx, w, errW, start, *selectedPoint) } // Preview rewind to show warnings about files that will be deleted preview, previewErr := start.PreviewRewind(ctx, *selectedPoint) - if previewErr == nil && preview != nil && len(preview.FilesToDelete) > 0 { - fmt.Fprintf(os.Stderr, "\nWarning: The following untracked files will be DELETED:\n") + if previewErr != nil { + fmt.Fprintf(errW, "Warning: could not preview rewind effects: %v\n", previewErr) + } else if preview != nil && len(preview.FilesToDelete) > 0 { + fmt.Fprintf(errW, "\nWarning: The following untracked files will be DELETED:\n") for _, f := range preview.FilesToDelete { - fmt.Fprintf(os.Stderr, " - %s\n", f) + fmt.Fprintf(errW, " - %s\n", f) } - fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(errW, "\n") } // Resolve agent once for use throughout @@ -455,7 +468,7 @@ func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, re ) // Perform the rewind - if err := start.Rewind(ctx, *selectedPoint); err != nil { + if err := start.Rewind(ctx, w, errW, *selectedPoint); err != nil { logging.Error(logCtx, "rewind failed", slog.String("checkpoint_id", selectedPoint.ID), slog.String("error", err.Error()), @@ -474,7 +487,7 @@ func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, re if selectedPoint.IsTaskCheckpoint { checkpoint, err := start.GetTaskCheckpoint(ctx, *selectedPoint) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to read task checkpoint: %v\n", err) + fmt.Fprintf(errW, "Warning: failed to read task checkpoint: %v\n", err) return nil } @@ -482,10 +495,10 @@ func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, re if checkpoint.CheckpointUUID != "" { // Use strategy-based transcript restoration for task checkpoints - if err := restoreTaskCheckpointTranscript(ctx, start, *selectedPoint, sessionID, checkpoint.CheckpointUUID, agent); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to restore truncated session transcript: %v\n", err) + if err := restoreTaskCheckpointTranscript(ctx, w, start, *selectedPoint, sessionID, checkpoint.CheckpointUUID, agent); err != nil { + fmt.Fprintf(errW, "Warning: failed to restore truncated session transcript: %v\n", err) } else { - fmt.Printf("Rewound to task checkpoint. %s\n", agent.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "✓ Rewound to task checkpoint. %s\n", agent.FormatResumeCommand(sessionID)) } return nil } @@ -521,18 +534,18 @@ func runRewindToInternal(ctx context.Context, commitID string, logsOnly bool, re if !restored { // Fall back to local file - if err := restoreSessionTranscript(ctx, transcriptFile, sessionID, agent); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to restore session transcript: %v\n", err) + if err := restoreSessionTranscript(ctx, w, transcriptFile, sessionID, agent); err != nil { + fmt.Fprintf(errW, "Warning: failed to restore session transcript: %v\n", err) } } - fmt.Printf("Rewound to %s. %s\n", selectedPoint.ID[:7], agent.FormatResumeCommand(sessionID)) + fmt.Fprintf(w, "✓ Rewound to %s. %s\n", selectedPoint.ID[:7], agent.FormatResumeCommand(sessionID)) return nil } // handleLogsOnlyRewindNonInteractive handles logs-only rewind in non-interactive mode. // Defaults to restoring logs only (no checkout) for safety. -func handleLogsOnlyRewindNonInteractive(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { +func handleLogsOnlyRewindNonInteractive(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { // Resolve agent once for use throughout agent, err := getAgent(point.Agent) if err != nil { @@ -548,7 +561,7 @@ func handleLogsOnlyRewindNonInteractive(ctx context.Context, start *strategy.Man slog.String("session_id", point.SessionID), ) - sessions, err := start.RestoreLogsOnly(ctx, point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind if err != nil { logging.Error(logCtx, "logs-only rewind failed", slog.String("checkpoint_id", point.ID), @@ -562,15 +575,15 @@ func handleLogsOnlyRewindNonInteractive(ctx context.Context, start *strategy.Man ) // Show resume commands for all sessions - printMultiSessionResumeCommands(sessions) + printMultiSessionResumeCommands(w, errW, sessions) - fmt.Println("Note: Working directory unchanged. Use interactive mode for full checkout.") + fmt.Fprintln(w, "Note: Working directory unchanged. Use interactive mode for full checkout.") return nil } // handleLogsOnlyResetNonInteractive handles reset in non-interactive mode. // This performs a git reset --hard to the target commit. -func handleLogsOnlyResetNonInteractive(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { +func handleLogsOnlyResetNonInteractive(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { // Resolve agent once for use throughout agent, err := getAgent(point.Agent) if err != nil { @@ -593,7 +606,7 @@ func handleLogsOnlyResetNonInteractive(ctx context.Context, start *strategy.Manu } // Restore logs first - sessions, err := start.RestoreLogsOnly(ctx, point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind if err != nil { logging.Error(logCtx, "logs-only reset failed during log restoration", slog.String("checkpoint_id", point.ID), @@ -620,10 +633,10 @@ func handleLogsOnlyResetNonInteractive(ctx context.Context, start *strategy.Manu shortID = shortID[:7] } - fmt.Printf("Reset branch to %s.\n", shortID) + fmt.Fprintf(w, "✓ Reset branch to %s.\n", shortID) // Show resume commands for all sessions - printMultiSessionResumeCommands(sessions) + printMultiSessionResumeCommands(w, errW, sessions) // Show recovery instructions if currentHead != "" && currentHead != point.ID { @@ -631,13 +644,13 @@ func handleLogsOnlyResetNonInteractive(ctx context.Context, start *strategy.Manu if len(currentShort) > 7 { currentShort = currentShort[:7] } - fmt.Printf("\nTo undo this reset: git reset --hard %s\n", currentShort) + fmt.Fprintf(w, "\nTo undo this reset: git reset --hard %s\n", currentShort) } return nil } -func restoreSessionTranscript(ctx context.Context, transcriptFile, sessionID string, agent agentpkg.Agent) error { +func restoreSessionTranscript(ctx context.Context, w io.Writer, transcriptFile, sessionID string, agent agentpkg.Agent) error { sessionFile, err := resolveTranscriptPath(ctx, sessionID, agent) if err != nil { return err @@ -648,7 +661,7 @@ func restoreSessionTranscript(ctx context.Context, transcriptFile, sessionID str return fmt.Errorf("failed to create agent session directory: %w", err) } - fmt.Fprintf(os.Stderr, "Copying transcript:\n From: %s\n To: %s\n", transcriptFile, sessionFile) + fmt.Fprintf(w, "Copying transcript:\n From: %s\n To: %s\n", transcriptFile, sessionFile) if err := copyFile(transcriptFile, sessionFile); err != nil { return fmt.Errorf("failed to copy transcript: %w", err) } @@ -740,7 +753,7 @@ func restoreSessionTranscriptFromShadow(ctx context.Context, commitHash, metadat // This is acceptable because task checkpoints are currently only created by Claude Code's // PostToolUse hook. If other agents gain sub-agent support, this will need a // format-aware refactor (agent-specific parsing, truncation, and serialization). -func restoreTaskCheckpointTranscript(ctx context.Context, strat *strategy.ManualCommitStrategy, point strategy.RewindPoint, sessionID, checkpointUUID string, agent agentpkg.Agent) error { +func restoreTaskCheckpointTranscript(ctx context.Context, w io.Writer, strat *strategy.ManualCommitStrategy, point strategy.RewindPoint, sessionID, checkpointUUID string, agent agentpkg.Agent) error { // Get transcript content from strategy content, err := strat.GetTaskCheckpointTranscript(ctx, point) if err != nil { @@ -766,7 +779,7 @@ func restoreTaskCheckpointTranscript(ctx context.Context, strat *strategy.Manual return fmt.Errorf("failed to create agent session directory: %w", err) } - fmt.Fprintf(os.Stderr, "Writing truncated transcript to: %s\n", sessionFile) + fmt.Fprintf(w, "Writing truncated transcript to: %s\n", sessionFile) if err := writeTranscript(sessionFile, truncated); err != nil { return fmt.Errorf("failed to write truncated transcript: %w", err) @@ -776,7 +789,7 @@ func restoreTaskCheckpointTranscript(ctx context.Context, strat *strategy.Manual } // handleLogsOnlyRewindInteractive handles rewind for logs-only points with a sub-choice menu. -func handleLogsOnlyRewindInteractive(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { +func handleLogsOnlyRewindInteractive(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { var action string form := NewAccessibleForm( @@ -795,18 +808,21 @@ func handleLogsOnlyRewindInteractive(ctx context.Context, start *strategy.Manual ) if err := form.Run(); err != nil { - return fmt.Errorf("action selection cancelled: %w", err) + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return fmt.Errorf("action selection failed: %w", err) } switch action { case "logs": - return handleLogsOnlyRestore(ctx, start, point) + return handleLogsOnlyRestore(ctx, w, errW, start, point) case "checkout": - return handleLogsOnlyCheckout(ctx, start, point, shortID) + return handleLogsOnlyCheckout(ctx, w, errW, start, point, shortID) case "reset": - return handleLogsOnlyReset(ctx, start, point, shortID) + return handleLogsOnlyReset(ctx, w, errW, start, point, shortID) case "cancel": - fmt.Println("Rewind cancelled.") + fmt.Fprintln(w, "Rewind cancelled.") return nil } @@ -814,7 +830,7 @@ func handleLogsOnlyRewindInteractive(ctx context.Context, start *strategy.Manual } // handleLogsOnlyRestore restores only the session logs without changing files. -func handleLogsOnlyRestore(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { +func handleLogsOnlyRestore(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint) error { // Resolve agent once for use throughout agent, err := getAgent(point.Agent) if err != nil { @@ -831,7 +847,7 @@ func handleLogsOnlyRestore(ctx context.Context, start *strategy.ManualCommitStra ) // Restore logs - sessions, err := start.RestoreLogsOnly(ctx, point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind if err != nil { logging.Error(logCtx, "logs-only restore failed", slog.String("checkpoint_id", point.ID), @@ -845,13 +861,13 @@ func handleLogsOnlyRestore(ctx context.Context, start *strategy.ManualCommitStra ) // Show resume commands for all sessions - fmt.Println("Restored session logs.") - printMultiSessionResumeCommands(sessions) + fmt.Fprintln(w, "✓ Restored session logs.") + printMultiSessionResumeCommands(w, errW, sessions) return nil } // handleLogsOnlyCheckout restores logs and checks out the commit (detached HEAD). -func handleLogsOnlyCheckout(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { +func handleLogsOnlyCheckout(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { // Resolve agent once for use throughout agent, err := getAgent(point.Agent) if err != nil { @@ -867,7 +883,7 @@ func handleLogsOnlyCheckout(ctx context.Context, start *strategy.ManualCommitStr slog.String("session_id", point.SessionID), ) - sessions, err := start.RestoreLogsOnly(ctx, point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind if err != nil { logging.Error(logCtx, "logs-only checkout failed during log restoration", slog.String("checkpoint_id", point.ID), @@ -888,12 +904,15 @@ func handleLogsOnlyCheckout(ctx context.Context, start *strategy.ManualCommitStr ) if err := confirmForm.Run(); err != nil { - return fmt.Errorf("confirmation cancelled: %w", err) + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return fmt.Errorf("confirmation failed: %w", err) } if !confirm { - fmt.Println("Checkout cancelled. Session logs were still restored.") - printMultiSessionResumeCommands(sessions) + fmt.Fprintln(w, "Checkout cancelled. Session logs were still restored.") + printMultiSessionResumeCommands(w, errW, sessions) return nil } @@ -910,13 +929,13 @@ func handleLogsOnlyCheckout(ctx context.Context, start *strategy.ManualCommitStr slog.String("checkpoint_id", point.ID), ) - fmt.Printf("Checked out %s (detached HEAD).\n", shortID) - printMultiSessionResumeCommands(sessions) + fmt.Fprintf(w, "✓ Checked out %s (detached HEAD).\n", shortID) + printMultiSessionResumeCommands(w, errW, sessions) return nil } // handleLogsOnlyReset restores logs and resets the branch to the commit (destructive). -func handleLogsOnlyReset(ctx context.Context, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { +func handleLogsOnlyReset(ctx context.Context, w, errW io.Writer, start *strategy.ManualCommitStrategy, point strategy.RewindPoint, shortID string) error { // Resolve agent once for use throughout agent, agentErr := getAgent(point.Agent) if agentErr != nil { @@ -932,7 +951,7 @@ func handleLogsOnlyReset(ctx context.Context, start *strategy.ManualCommitStrate slog.String("session_id", point.SessionID), ) - sessions, restoreErr := start.RestoreLogsOnly(ctx, point, true) // force=true for explicit rewind + sessions, restoreErr := start.RestoreLogsOnly(ctx, w, errW, point, true) // force=true for explicit rewind if restoreErr != nil { logging.Error(logCtx, "logs-only reset failed during log restoration", slog.String("checkpoint_id", point.ID), @@ -983,12 +1002,15 @@ func handleLogsOnlyReset(ctx context.Context, start *strategy.ManualCommitStrate ) if err := confirmForm.Run(); err != nil { - return fmt.Errorf("confirmation cancelled: %w", err) + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return fmt.Errorf("confirmation failed: %w", err) } if !confirm { - fmt.Println("Reset cancelled. Session logs were still restored.") - printMultiSessionResumeCommands(sessions) + fmt.Fprintln(w, "Reset cancelled. Session logs were still restored.") + printMultiSessionResumeCommands(w, errW, sessions) return nil } @@ -1005,8 +1027,8 @@ func handleLogsOnlyReset(ctx context.Context, start *strategy.ManualCommitStrate slog.String("checkpoint_id", point.ID), ) - fmt.Printf("Reset branch to %s.\n", shortID) - printMultiSessionResumeCommands(sessions) + fmt.Fprintf(w, "✓ Reset branch to %s.\n", shortID) + printMultiSessionResumeCommands(w, errW, sessions) // Show recovery instructions if currentHead != "" && currentHead != point.ID { @@ -1014,7 +1036,7 @@ func handleLogsOnlyReset(ctx context.Context, start *strategy.ManualCommitStrate if len(currentShort) > 7 { currentShort = currentShort[:7] } - fmt.Printf("\nTo undo this reset: git reset --hard %s\n", currentShort) + fmt.Fprintf(w, "\nTo undo this reset: git reset --hard %s\n", currentShort) } return nil @@ -1167,45 +1189,25 @@ func sanitizeForTerminal(s string) string { // printMultiSessionResumeCommands prints resume commands for restored sessions. // Each session may have a different agent, so per-session agent resolution is used. -func printMultiSessionResumeCommands(sessions []strategy.RestoredSession) { +func printMultiSessionResumeCommands(w, errW io.Writer, sessions []strategy.RestoredSession) { if len(sessions) == 0 { return } if len(sessions) > 1 { - fmt.Printf("\nRestored %d sessions. Resume with:\n", len(sessions)) + fmt.Fprintf(w, "\n✓ Restored %d sessions. To continue, run:\n", len(sessions)) + } else { + fmt.Fprintf(w, "✓ Restored session %s.\n", sessions[0].SessionID) + fmt.Fprintf(w, "\nTo continue this session, run:\n") } + isMulti := len(sessions) > 1 for i, sess := range sessions { ag, err := strategy.ResolveAgentForRewind(sess.Agent) if err != nil { - fmt.Fprintf(os.Stderr, " Warning: could not resolve agent %q for session %s, skipping\n", sess.Agent, sess.SessionID) + fmt.Fprintf(errW, " Warning: could not resolve agent %q for session %s, skipping\n", sess.Agent, sess.SessionID) continue } - - cmd := ag.FormatResumeCommand(sess.SessionID) - - if len(sessions) > 1 { - // Add "(most recent)" label to the last session - if i == len(sessions)-1 { - if sess.Prompt != "" { - fmt.Printf(" %s # %s (most recent)\n", cmd, sess.Prompt) - } else { - fmt.Printf(" %s # (most recent)\n", cmd) - } - } else { - if sess.Prompt != "" { - fmt.Printf(" %s # %s\n", cmd, sess.Prompt) - } else { - fmt.Printf(" %s\n", cmd) - } - } - } else { - if sess.Prompt != "" { - fmt.Printf("%s # %s\n", cmd, sess.Prompt) - } else { - fmt.Printf("%s\n", cmd) - } - } + printSessionCommand(w, ag.FormatResumeCommand(sess.SessionID), sess.Prompt, isMulti, i == len(sessions)-1) } } diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index af9699b9a..fb4cc54c7 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -296,23 +296,18 @@ func writeActiveSessions(ctx context.Context, w io.Writer, sty statusStyles) { agentLabel = unknownPlaceholder } - shortID := st.SessionID - if len(shortID) > 7 { - shortID = shortID[:7] - } - - // Line 1: Agent (model) · shortID + // Line 1: Agent (model) · sessionID if st.ModelName != "" { fmt.Fprintf(w, "%s %s %s %s\n", sty.render(sty.agent, agentLabel), sty.render(sty.dim, "("+st.ModelName+")"), sty.render(sty.dim, "·"), - shortID) + st.SessionID) } else { fmt.Fprintf(w, "%s %s %s\n", sty.render(sty.agent, agentLabel), sty.render(sty.dim, "·"), - shortID) + st.SessionID) } // Line 2: > "first prompt" (chevron + quoted, truncated) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 4b297de48..b19006dac 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -442,9 +442,15 @@ func TestWriteActiveSessions(t *testing.T) { t.Errorf("Expected '%s' for missing agent type, got: %s", unknownPlaceholder, output) } - // Should contain truncated session IDs - if !strings.Contains(output, "abc-123") { - t.Errorf("Expected truncated session ID 'abc-123', got: %s", output) + // Should contain full session IDs + if !strings.Contains(output, "abc-1234-session") { + t.Errorf("Expected full session ID 'abc-1234-session', got: %s", output) + } + if !strings.Contains(output, "def-5678-session") { + t.Errorf("Expected full session ID 'def-5678-session', got: %s", output) + } + if !strings.Contains(output, "ghi-9012-session") { + t.Errorf("Expected full session ID 'ghi-9012-session', got: %s", output) } // Should contain first prompts with chevron @@ -455,7 +461,7 @@ func TestWriteActiveSessions(t *testing.T) { // Session without LastPrompt should NOT show a prompt line lines := strings.Split(output, "\n") for _, line := range lines { - if strings.Contains(line, "ghi-901") { + if strings.Contains(line, "ghi-9012-session") { if strings.Contains(line, "\"") { t.Errorf("Session without prompt should not show quoted text on first line, got: %s", line) } diff --git a/cmd/entire/cli/strategy/manual_commit_reset.go b/cmd/entire/cli/strategy/manual_commit_reset.go index 21501a3e3..ea7c2031d 100644 --- a/cmd/entire/cli/strategy/manual_commit_reset.go +++ b/cmd/entire/cli/strategy/manual_commit_reset.go @@ -3,6 +3,7 @@ package strategy import ( "context" "fmt" + "io" "os" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -18,7 +19,7 @@ func isAccessibleMode() bool { // Reset deletes the shadow branch and session state for the current HEAD. // This allows starting fresh without existing checkpoints. -func (s *ManualCommitStrategy) Reset(ctx context.Context) error { +func (s *ManualCommitStrategy) Reset(ctx context.Context, w, errW io.Writer) error { repo, err := OpenRepository(ctx) if err != nil { return fmt.Errorf("failed to open git repository: %w", err) @@ -56,7 +57,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // If nothing to reset, return early if !hasShadowBranch && len(sessions) == 0 { - fmt.Fprintf(os.Stderr, "Nothing to reset for %s\n", shadowBranchName) + fmt.Fprintf(w, "Nothing to reset for %s\n", shadowBranchName) return nil } @@ -64,7 +65,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { clearedSessions := make([]string, 0) for _, state := range sessions { if err := s.clearSessionState(ctx, state.SessionID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear session state for %s: %v\n", state.SessionID, err) + fmt.Fprintf(errW, "Warning: failed to clear session state for %s: %v\n", state.SessionID, err) } else { clearedSessions = append(clearedSessions, state.SessionID) } @@ -73,7 +74,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // Report cleared session states with session IDs if len(clearedSessions) > 0 { for _, sessionID := range clearedSessions { - fmt.Fprintf(os.Stderr, "Cleared session state for %s\n", sessionID) + fmt.Fprintf(w, "✓ Cleared session state for %s\n", sessionID) } } @@ -82,7 +83,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { if err := DeleteBranchCLI(ctx, shadowBranchName); err != nil { return fmt.Errorf("failed to delete shadow branch: %w", err) } - fmt.Fprintf(os.Stderr, "Deleted shadow branch %s\n", shadowBranchName) + fmt.Fprintf(w, "✓ Deleted shadow branch %s\n", shadowBranchName) } return nil @@ -90,7 +91,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // ResetSession clears a single session's state and removes the shadow branch // if no other sessions reference it. File changes remain in the working directory. -func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID string) error { +func (s *ManualCommitStrategy) ResetSession(ctx context.Context, w, errW io.Writer, sessionID string) error { // Load the session state state, err := s.loadSessionState(ctx, sessionID) if err != nil { @@ -104,7 +105,7 @@ func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID strin if err := s.clearSessionState(ctx, sessionID); err != nil { return fmt.Errorf("failed to clear session state: %w", err) } - fmt.Fprintf(os.Stderr, "Cleared session state for %s\n", sessionID) + fmt.Fprintf(w, "✓ Cleared session state for %s\n", sessionID) // Determine the shadow branch for this session shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) @@ -117,12 +118,12 @@ func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID strin // Clean up shadow branch if no other sessions need it if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clean up shadow branch %s: %v\n", shadowBranchName, err) + fmt.Fprintf(errW, "Warning: failed to clean up shadow branch %s: %v\n", shadowBranchName, err) } else { // Check if it was actually deleted via git CLI (go-git's cache // may be stale after CLI-based deletion with packed refs) if err := branchExistsCLI(ctx, shadowBranchName); err != nil { - fmt.Fprintf(os.Stderr, "Deleted shadow branch %s\n", shadowBranchName) + fmt.Fprintf(w, "✓ Deleted shadow branch %s\n", shadowBranchName) } } diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index 6e95b1259..d0b0ddb83 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -275,7 +276,7 @@ func ResolveLatestCheckpointFromMap(cpIDs []id.CheckpointID, infoMap map[id.Chec // Rewind restores the working directory to a checkpoint. // -func (s *ManualCommitStrategy) Rewind(ctx context.Context, point RewindPoint) error { +func (s *ManualCommitStrategy) Rewind(ctx context.Context, w, errW io.Writer, point RewindPoint) error { repo, err := OpenRepository(ctx) if err != nil { return fmt.Errorf("failed to open git repository: %w", err) @@ -374,7 +375,7 @@ func (s *ManualCommitStrategy) Rewind(ctx context.Context, point RewindPoint) er untrackedNow, err := collectUntrackedFiles(ctx) if err != nil { // Non-fatal - continue with restoration - fmt.Fprintf(os.Stderr, "Warning: error listing untracked files: %v\n", err) + fmt.Fprintf(errW, "Warning: error listing untracked files: %v\n", err) } for _, relPath := range untrackedNow { // If file is in checkpoint, it will be restored @@ -394,7 +395,7 @@ func (s *ManualCommitStrategy) Rewind(ctx context.Context, point RewindPoint) er // File is untracked and not in checkpoint - delete it via os.Root if removeErr := osroot.Remove(repoRootHandle, relPath); removeErr == nil { - fmt.Fprintf(os.Stderr, " Deleted: %s\n", relPath) + fmt.Fprintf(w, " Deleted: %s\n", relPath) } } @@ -432,20 +433,20 @@ func (s *ManualCommitStrategy) Rewind(ctx context.Context, point RewindPoint) er return fmt.Errorf("failed to write file %s: %w", f.Name, err) } - fmt.Fprintf(os.Stderr, " Restored: %s\n", f.Name) + fmt.Fprintf(w, " Restored: %s\n", f.Name) return nil }) if err != nil { return fmt.Errorf("failed to iterate tree files: %w", err) } - fmt.Println() + fmt.Fprintln(w) if len(point.ID) >= 7 { - fmt.Printf("Restored files from shadow commit %s\n", point.ID[:7]) + fmt.Fprintf(w, "Restored files from shadow commit %s\n", point.ID[:7]) } else { - fmt.Printf("Restored files from shadow commit %s\n", point.ID) + fmt.Fprintf(w, "Restored files from shadow commit %s\n", point.ID) } - fmt.Println() + fmt.Fprintln(w) return nil } @@ -611,7 +612,7 @@ func (s *ManualCommitStrategy) PreviewRewind(ctx context.Context, point RewindPo // When multiple sessions were condensed to the same checkpoint, ALL sessions are restored. // If force is false, prompts for confirmation when local logs have newer timestamps. // Returns info about each restored session so callers can print correct per-session resume commands. -func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point RewindPoint, force bool) ([]RestoredSession, error) { +func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.Writer, point RewindPoint, force bool) ([]RestoredSession, error) { if !point.IsLogsOnly { return nil, errors.New("not a logs-only rewind point") } @@ -652,12 +653,12 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind } } if hasConflicts { - shouldOverwrite, promptErr := PromptOverwriteNewerLogs(sessions) + shouldOverwrite, promptErr := PromptOverwriteNewerLogs(errW, sessions) if promptErr != nil { return nil, promptErr } if !shouldOverwrite { - fmt.Fprintf(os.Stderr, "Resume cancelled. Local session logs preserved.\n") + fmt.Fprintf(w, "Resume cancelled. Local session logs preserved.\n") return nil, nil } } @@ -666,7 +667,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind // Count sessions to restore totalSessions := len(summary.Sessions) if totalSessions > 1 { - fmt.Fprintf(os.Stderr, "Restoring %d sessions from checkpoint:\n", totalSessions) + fmt.Fprintf(w, "Restoring %d sessions from checkpoint:\n", totalSessions) } // Restore all sessions (oldest to newest, using 0-based indexing) @@ -674,7 +675,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind for i := range totalSessions { content, readErr := store.ReadSessionContent(ctx, point.CheckpointID, i) if readErr != nil { - fmt.Fprintf(os.Stderr, " Warning: failed to read session %d: %v\n", i, readErr) + fmt.Fprintf(errW, " Warning: failed to read session %d: %v\n", i, readErr) continue } if content == nil || len(content.Transcript) == 0 { @@ -683,25 +684,25 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind sessionID := content.Metadata.SessionID if sessionID == "" { - fmt.Fprintf(os.Stderr, " Warning: session %d has no session ID, skipping\n", i) + fmt.Fprintf(errW, " Warning: session %d has no session ID, skipping\n", i) continue } // Resolve per-session agent from metadata — skip if agent is unknown if content.Metadata.Agent == "" { - fmt.Fprintf(os.Stderr, " Warning: session %d (%s) has no agent metadata, skipping (cannot determine target directory)\n", i, sessionID) + fmt.Fprintf(errW, " Warning: session %d (%s) has no agent metadata, skipping (cannot determine target directory)\n", i, sessionID) continue } sessionAgent, agErr := ResolveAgentForRewind(content.Metadata.Agent) if agErr != nil { - fmt.Fprintf(os.Stderr, " Warning: session %d (%s) has unknown agent %q, skipping\n", i, sessionID, content.Metadata.Agent) + fmt.Fprintf(errW, " Warning: session %d (%s) has unknown agent %q, skipping\n", i, sessionID, content.Metadata.Agent) continue } // Compute transcript path from current repo location for cross-machine portability. sessionAgentDir, dirErr := sessionAgent.GetSessionDir(repoRoot) if dirErr != nil { - fmt.Fprintf(os.Stderr, " Warning: failed to get session dir for session %d: %v\n", i, dirErr) + fmt.Fprintf(errW, " Warning: failed to get session dir for session %d: %v\n", i, dirErr) continue } sessionFile := sessionAgent.ResolveSessionFile(sessionAgentDir, sessionID) @@ -713,19 +714,19 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind isLatest := i == totalSessions-1 if promptPreview != "" { if isLatest { - fmt.Fprintf(os.Stderr, " Session %d (latest): %s\n", i+1, promptPreview) + fmt.Fprintf(w, " Session %d (latest): %s\n", i+1, promptPreview) } else { - fmt.Fprintf(os.Stderr, " Session %d: %s\n", i+1, promptPreview) + fmt.Fprintf(w, " Session %d: %s\n", i+1, promptPreview) } } - fmt.Fprintf(os.Stderr, " Writing to: %s\n", sessionFile) + fmt.Fprintf(w, " Writing to: %s\n", sessionFile) } else { - fmt.Fprintf(os.Stderr, "Writing transcript to: %s\n", sessionFile) + fmt.Fprintf(w, "Writing transcript to: %s\n", sessionFile) } // Ensure parent directory exists (session file may be in a different dir than sessionAgentDir) if mkdirErr := os.MkdirAll(filepath.Dir(sessionFile), 0o750); mkdirErr != nil { - fmt.Fprintf(os.Stderr, " Warning: failed to create directory: %v\n", mkdirErr) + fmt.Fprintf(errW, " Warning: failed to create directory: %v\n", mkdirErr) continue } @@ -738,7 +739,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, point Rewind } if writeErr := sessionAgent.WriteSession(ctx, agentSession); writeErr != nil { if totalSessions > 1 { - fmt.Fprintf(os.Stderr, " Warning: failed to write session: %v\n", writeErr) + fmt.Fprintf(errW, " Warning: failed to write session: %v\n", writeErr) continue } return nil, fmt.Errorf("failed to write session: %w", writeErr) @@ -900,7 +901,7 @@ func StatusToText(status SessionRestoreStatus) string { // PromptOverwriteNewerLogs asks the user for confirmation to overwrite local // session logs that have newer timestamps than the checkpoint versions. -func PromptOverwriteNewerLogs(sessions []SessionRestoreInfo) (bool, error) { +func PromptOverwriteNewerLogs(errW io.Writer, sessions []SessionRestoreInfo) (bool, error) { // Separate conflicting and non-conflicting sessions var conflicting, nonConflicting []SessionRestoreInfo for _, s := range sessions { @@ -911,32 +912,32 @@ func PromptOverwriteNewerLogs(sessions []SessionRestoreInfo) (bool, error) { } } - fmt.Fprintf(os.Stderr, "\nWarning: Local session log(s) have newer entries than the checkpoint:\n") + fmt.Fprintf(errW, "\nWarning: Local session log(s) have newer entries than the checkpoint:\n") for _, info := range conflicting { // Show prompt if available, otherwise fall back to session ID if info.Prompt != "" { - fmt.Fprintf(os.Stderr, " \"%s\"\n", info.Prompt) + fmt.Fprintf(errW, " \"%s\"\n", info.Prompt) } else { - fmt.Fprintf(os.Stderr, " Session: %s\n", info.SessionID) + fmt.Fprintf(errW, " Session: %s\n", info.SessionID) } - fmt.Fprintf(os.Stderr, " Local last entry: %s\n", info.LocalTime.Local().Format("2006-01-02 15:04:05")) - fmt.Fprintf(os.Stderr, " Checkpoint last entry: %s\n", info.CheckpointTime.Local().Format("2006-01-02 15:04:05")) + fmt.Fprintf(errW, " Local last entry: %s\n", info.LocalTime.Local().Format("2006-01-02 15:04:05")) + fmt.Fprintf(errW, " Checkpoint last entry: %s\n", info.CheckpointTime.Local().Format("2006-01-02 15:04:05")) } // Show non-conflicting sessions with their status if len(nonConflicting) > 0 { - fmt.Fprintf(os.Stderr, "\nThese other session(s) will also be restored:\n") + fmt.Fprintf(errW, "\nThese other session(s) will also be restored:\n") for _, info := range nonConflicting { statusText := StatusToText(info.Status) if info.Prompt != "" { - fmt.Fprintf(os.Stderr, " \"%s\" %s\n", info.Prompt, statusText) + fmt.Fprintf(errW, " \"%s\" %s\n", info.Prompt, statusText) } else { - fmt.Fprintf(os.Stderr, " Session: %s %s\n", info.SessionID, statusText) + fmt.Fprintf(errW, " Session: %s %s\n", info.SessionID, statusText) } } } - fmt.Fprintf(os.Stderr, "\nOverwriting will lose the newer local entries.\n\n") + fmt.Fprintf(errW, "\nOverwriting will lose the newer local entries.\n\n") var confirmed bool form := huh.NewForm( diff --git a/cmd/entire/cli/strategy/rewind_test.go b/cmd/entire/cli/strategy/rewind_test.go index fe00b4722..6b79633b1 100644 --- a/cmd/entire/cli/strategy/rewind_test.go +++ b/cmd/entire/cli/strategy/rewind_test.go @@ -2,6 +2,7 @@ package strategy import ( "context" + "io" "os" "path/filepath" "testing" @@ -337,7 +338,7 @@ func TestShadowStrategy_Rewind_FromSubdirectory(t *testing.T) { Date: time.Now(), } - if err := s.Rewind(context.Background(), point); err != nil { + if err := s.Rewind(context.Background(), io.Discard, io.Discard, point); err != nil { t.Fatalf("Rewind() error = %v", err) } @@ -468,7 +469,7 @@ func TestShadowStrategy_Rewind_FromRepoRoot(t *testing.T) { Date: time.Now(), } - if err := s.Rewind(context.Background(), point); err != nil { + if err := s.Rewind(context.Background(), io.Discard, io.Discard, point); err != nil { t.Fatalf("Rewind() error = %v", err) } diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 8795a1ba2..07456ed75 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -240,7 +240,7 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str if interactive { // Interactive flow: title → body → branch (derived) → status if err := runTrailCreateInteractive(&title, &body, &branch, &statusStr); err != nil { - return err + return handleFormCancellation(w, "Trail creation", err) } } else { // Non-interactive: derive missing values from provided flags @@ -265,9 +265,9 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str if err := createBranch(repo, branch); err != nil { return fmt.Errorf("failed to create branch %q: %w", branch, err) } - fmt.Fprintf(w, "Created branch %s\n", branch) + fmt.Fprintf(w, "✓ Created branch %s\n", branch) } else if currentBranch != branch { - fmt.Fprintf(errW, "Note: trail will be created for branch %q (not the current branch)\n", branch) + fmt.Fprintf(w, "Note: trail will be created for branch %q (not the current branch)\n", branch) } // Check if trail already exists for this branch @@ -317,14 +317,14 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str return fmt.Errorf("failed to create trail: %w", err) } - fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) + fmt.Fprintf(w, "✓ Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) // Push the branch and trail data to origin if needsCreation { if err := pushBranchToOrigin(branch); err != nil { fmt.Fprintf(errW, "Warning: failed to push branch: %v\n", err) } else { - fmt.Fprintf(w, "Pushed branch %s to origin\n", branch) + fmt.Fprintf(w, "✓ Pushed branch %s to origin\n", branch) } } if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { @@ -351,7 +351,7 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str if err := CheckoutBranch(context.Background(), branch); err != nil { return fmt.Errorf("failed to checkout branch %q: %w", branch, err) } - fmt.Fprintf(w, "Switched to branch %s\n", branch) + fmt.Fprintf(w, "✓ Switched to branch %s\n", branch) } } @@ -439,7 +439,7 @@ func runTrailUpdate(w, errW io.Writer, statusStr, title, body, branch string, la ), ) if formErr := form.Run(); formErr != nil { - return fmt.Errorf("form cancelled: %w", formErr) + return handleFormCancellation(w, "Trail update", formErr) } } @@ -474,7 +474,7 @@ func runTrailUpdate(w, errW io.Writer, statusStr, title, body, branch string, la return fmt.Errorf("failed to update trail: %w", err) } - fmt.Fprintf(w, "Updated trail for branch %s\n", branch) + fmt.Fprintf(w, "✓ Updated trail for branch %s\n", branch) if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) diff --git a/cmd/entire/cli/utils.go b/cmd/entire/cli/utils.go index c0928a3ec..fbde35738 100644 --- a/cmd/entire/cli/utils.go +++ b/cmd/entire/cli/utils.go @@ -2,7 +2,9 @@ package cli import ( "context" + "errors" "fmt" + "io" "os" "path/filepath" @@ -37,6 +39,34 @@ func NewAccessibleForm(groups ...*huh.Group) *huh.Form { return form } +// handleFormCancellation handles cancellation from huh form prompts. +// If err is a user abort (Ctrl+C), it prints "[action] cancelled." to w and returns nil. +// Otherwise it returns the error as-is. +func handleFormCancellation(w io.Writer, action string, err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintf(w, "%s cancelled.\n", action) + return nil + } + return err +} + +// printSessionCommand writes a single session resume command line to w. +// It appends a "(most recent)" label to the last entry in a multi-session list, +// and a "# prompt" comment when a prompt is available. +func printSessionCommand(w io.Writer, resumeCmd, prompt string, isMulti, isLast bool) { + comment := "" + if isMulti && isLast { + if prompt != "" { + comment = fmt.Sprintf(" # %s (most recent)", prompt) + } else { + comment = " # (most recent)" + } + } else if prompt != "" { + comment = " # " + prompt + } + fmt.Fprintf(w, " %s%s\n", resumeCmd, comment) +} + // fileExists checks if a file exists func fileExists(path string) bool { _, err := os.Stat(path) diff --git a/cmd/entire/cli/utils_test.go b/cmd/entire/cli/utils_test.go index d52cd440a..147775320 100644 --- a/cmd/entire/cli/utils_test.go +++ b/cmd/entire/cli/utils_test.go @@ -1,14 +1,65 @@ package cli import ( + "bytes" + "errors" "os" "path/filepath" "testing" + "github.com/charmbracelet/huh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestHandleFormCancellation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action string + err error + wantOut string + wantErr bool + }{ + { + name: "user abort prints cancelled and returns nil", + action: "Reset", + err: huh.ErrUserAborted, + wantOut: "Reset cancelled.\n", + wantErr: false, + }, + { + name: "action name used in cancelled message", + action: "Trail creation", + err: huh.ErrUserAborted, + wantOut: "Trail creation cancelled.\n", + wantErr: false, + }, + { + name: "non-abort error is returned as-is", + action: "Reset", + err: errors.New("form exploded"), + wantOut: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var out bytes.Buffer + err := handleFormCancellation(&out, tt.action, tt.err) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantOut, out.String()) + }) + } +} + func TestCopyFile_HappyPath(t *testing.T) { t.Parallel()