diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4f364ce..0b62471 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -178,10 +178,10 @@ func promptChoice(ctx context.Context, input io.Reader, output io.Writer, decisi return "", fmt.Errorf("missing recovery decision") } choice := "cancel" - selectField := huh.NewSelect[string]().Title("A previous review attempt exists").Description("Attempt: "+decision.AttemptID+"\nRecover saved feedback, replace the review, or leave it unchanged.").Options( - huh.NewOption("Recover saved review (reopen tuicr)", "recover"), - huh.NewOption("Replace with a new review", "replace"), - huh.NewOption("Cancel", "cancel"), + selectField := huh.NewSelect[string]().Title("Resume a saved review?").Description(recoveryDescription(decision)).Options( + huh.NewOption("Recover — reopen tuicr with saved progress and comments", "recover"), + huh.NewOption("Replace — create a new attempt (keep prior history)", "replace"), + huh.NewOption("Cancel — keep the saved review unchanged", "cancel"), ).Value(&choice) err := huh.NewForm(huh.NewGroup(selectField)).WithInput(input).WithOutput(output).RunWithContext(ctx) return choice, err diff --git a/internal/cli/recovery_test.go b/internal/cli/recovery_test.go new file mode 100644 index 0000000..4dac43d --- /dev/null +++ b/internal/cli/recovery_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/nnutter/git-review/internal/review" + "github.com/stretchr/testify/require" +) + +func TestRecoveryDecisionExplainsSavedWork(t *testing.T) { + for _, tc := range []struct { + name string + saved jsontext.Value + want string + replacement string + }{ + {"saved progress", jsontext.Value(`{"reviewed":1,"files":2,"comments":[{"content":"Fix this"}],"sessionNotes":"Check this"}`), "Saved progress: 1/2 files reviewed · Comments: 1 · session notes saved", "changed"}, + {"missing progress", nil, "Saved progress: unavailable", "same"}, + {"missing counters", jsontext.Value(`{"comments":[]}`), "Saved progress: unavailable", "same"}, + {"no replacement", nil, "Saved progress: unavailable", "none"}, + } { + t.Run(tc.name, func(t *testing.T) { + saved := review.Stack{Base: "base", Tip: "bbbbbbbbbbbb", Commits: []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}, Subject: "Fix recovery\x1b[31m\nhandling"} + current := review.Stack{Base: "base", Tip: "cccccccccccc", Commits: []string{"cccccccccccc"}, Subject: "Revised recovery"} + result := review.Result{Command: "run", Status: "decision_required", Decision: &review.Decision{ + AttemptID: "internal-id", + Attempt: review.Attempt{Stack: saved, Status: "incomplete", StartedAt: "2026-10-16T12:00:00Z", Review: tc.saved}, + Replacement: ¤t, + }} + switch tc.replacement { + case "same": + result.Decision.Replacement = &saved + case "none": + result.Decision.Replacement = nil + } + text, err := renderResult(result, false) + require.NoError(t, err) + require.Contains(t, text, "Saved commits (inclusive): aaaaaaaa..bbbbbbbb — Fix recovery handling") + require.Contains(t, text, "Started: 2026-10-16T12:00:00Z · Status: Incomplete") + require.Contains(t, text, tc.want) + if tc.replacement == "changed" { + require.Contains(t, text, "New review commits (inclusive): cccccccc — Revised recovery") + } else { + require.NotContains(t, text, "New review commits") + } + if tc.replacement == "none" { + require.Contains(t, text, "No current replacement scope. Select one with git-review run .") + } + require.Contains(t, text, "Replacement keeps the prior attempt in history. Tuicr may reuse its saved session.") + require.NotContains(t, text, "internal-id") + }) + } +} diff --git a/internal/cli/render.go b/internal/cli/render.go index b47915f..ec2f333 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -70,12 +70,50 @@ func renderResult(result review.Result, verbose bool) (string, error) { fmt.Fprintln(&out, message) } if result.Decision != nil { - fmt.Fprintf(&out, "Attempt: %s\n", safeText(result.Decision.AttemptID)) + fmt.Fprintln(&out, recoveryDescription(result.Decision)) fmt.Fprintln(&out, "Choose git-review run --recover, git-review run --replace, or leave the attempt unchanged.") } return strings.TrimSpace(out.String()), nil } +func recoveryDescription(decision *review.Decision) string { + var out strings.Builder + attempt := decision.Attempt + fmt.Fprintf(&out, "Saved commits (inclusive): %s — %s\n", shortStack(attempt.Stack), safeText(attempt.Stack.Subject)) + started := safeText(attempt.StartedAt) + if started == "" { + started = "unavailable" + } + fmt.Fprintf(&out, "Started: %s · Status: %s\n", started, statusLabel(attempt.Status)) + var saved struct { + Reviewed *int `json:"reviewed"` + Files *int `json:"files"` + Comments []any `json:"comments"` + SessionNotes *string `json:"sessionNotes"` + } + if len(attempt.Review) == 0 || json.Unmarshal(attempt.Review, &saved) != nil || saved.Reviewed == nil || saved.Files == nil { + fmt.Fprintln(&out, "Saved progress: unavailable") + } else { + fmt.Fprintf(&out, "Saved progress: %d/%d files reviewed · Comments: %d", *saved.Reviewed, *saved.Files, len(saved.Comments)) + if saved.SessionNotes != nil && *saved.SessionNotes != "" { + fmt.Fprint(&out, " · session notes saved") + } + fmt.Fprintln(&out) + } + if attempt.Error != "" { + fmt.Fprintln(&out, "Last error: "+safeText(attempt.Error)) + } + if current := decision.Replacement; current != nil { + if current.Base != attempt.Stack.Base || current.Tip != attempt.Stack.Tip { + fmt.Fprintf(&out, "New review commits (inclusive): %s — %s\n", shortStack(*current), safeText(current.Subject)) + } + } else { + fmt.Fprintln(&out, "No current replacement scope. Select one with git-review run .") + } + fmt.Fprint(&out, "Replacement keeps the prior attempt in history. Tuicr may reuse its saved session.") + return out.String() +} + func renderDetails(out *strings.Builder, entries []review.Entry) error { for _, entry := range entries { if entry.Attempt != nil { diff --git a/internal/cli/tuicr_functional_test.go b/internal/cli/tuicr_functional_test.go index 40de62b..e944e1c 100644 --- a/internal/cli/tuicr_functional_test.go +++ b/internal/cli/tuicr_functional_test.go @@ -204,7 +204,10 @@ func TestRealTuicrRecoveryPrompt(t *testing.T) { require.Equal(t, "incomplete", prior.Status) require.Len(t, prior.Entries, 1) prompt := startTerminal(t, binary, dir, env, "run") - prompt.waitFor(t, "A previous review attempt exists") + prompt.waitFor(t, "Resume a saved review?") + prompt.waitFor(t, "Saved commits (inclusive):") + prompt.waitFor(t, "Saved progress: 1/2 files reviewed") + prompt.waitFor(t, "Replace — create a new attempt (keep prior history)") switch choice { case "recover": prompt.send(t, "kk\r") diff --git a/internal/review/run.go b/internal/review/run.go index d5a43be..9ce7998 100644 --- a/internal/review/run.go +++ b/internal/review/run.go @@ -306,7 +306,7 @@ func (r Reviewer) recover(plan reviewPlan, attempt Attempt, selector string) (Re } result := Result{Version: 1, Command: "run", Status: finished.Status, View: plan.view, Entries: []Entry{{Stack: finished.Stack, Status: finished.Status, Attempt: &finished}}, Active: []Attempt{}} if finished.Status == "incomplete" { - result.Decision = &Decision{AttemptID: finished.ID, StackKey: finished.Stack.Key, Choices: []string{"recover", "replace", "cancel"}} + result.Decision = recoveryDecision(plan, finished) } return result, nil } @@ -377,7 +377,7 @@ func (r Reviewer) reviewStack(ctx context.Context, options ReviewOptions, visite if plan.prior != nil { if options.Choice == "" { result.Status = "decision_required" - result.Decision = &Decision{AttemptID: plan.prior.ID, StackKey: plan.prior.Stack.Key, Choices: []string{"recover", "replace", "cancel"}} + result.Decision = recoveryDecision(plan, *plan.prior) return result, nil } } else { @@ -522,6 +522,19 @@ func recordFailure(path, id string, cause error) error { }) } +func recoveryDecision(plan reviewPlan, attempt Attempt) *Decision { + decision := &Decision{ + AttemptID: attempt.ID, + StackKey: attempt.Stack.Key, + Choices: []string{"recover", "replace", "cancel"}, + Attempt: attempt, + } + if stack, err := replacementStack(plan); err == nil { + decision.Replacement = &stack + } + return decision +} + func replacementStack(plan reviewPlan) (Stack, error) { if plan.stack == nil { return Stack{}, fmt.Errorf("no stack selected") diff --git a/internal/review/run_test.go b/internal/review/run_test.go index da7f7d9..3c2f5b9 100644 --- a/internal/review/run_test.go +++ b/internal/review/run_test.go @@ -138,6 +138,9 @@ func TestInterruptedReviewRecoveryAndReplacement(t *testing.T) { before, err := (Store{Path: view.Path}).Load() require.NoError(t, err) require.Len(t, before.Attempts, 1) + require.Equal(t, before.Attempts[0], result.Decision.Attempt) + require.NotNil(t, result.Decision.Replacement) + require.Equal(t, view.Stacks[0], *result.Decision.Replacement) path, err := WorktreePath(view.Stacks[0].Repo) require.NoError(t, err) scope := view.Stacks[0] diff --git a/internal/review/service.go b/internal/review/service.go index 3656244..44832e6 100644 --- a/internal/review/service.go +++ b/internal/review/service.go @@ -38,6 +38,9 @@ type Decision struct { AttemptID string `json:"attemptId"` StackKey string `json:"stackKey"` Choices []string `json:"choices"` + Attempt Attempt `json:"attempt"` + // Replacement is the current scope a new attempt would review, if available. + Replacement *Stack `json:"replacement,omitzero"` } // StateCount counts current feature commits in one review state.