From 555e3f39c9cf7e8c0906b4439f650ce5953c778d Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 16 Oct 2026 10:31:52 -0500 Subject: [PATCH 1/2] Include saved work and replacement scope in recovery decisions Include the saved attempt and available replacement scope so the command line can show what recovery resumes and which commits a new attempt would review. Keep existing decision identifiers and choices for JSON consumers. --- internal/review/run.go | 17 +++++++++++++++-- internal/review/run_test.go | 3 +++ internal/review/service.go | 3 +++ 3 files changed, 21 insertions(+), 2 deletions(-) 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. From edc5997833148f0427019ff1cee772be15f001d0 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 16 Oct 2026 10:35:35 -0500 Subject: [PATCH 2/2] Show saved review context before recovery or replacement Show saved review details in interactive and noninteractive output so users can choose recovery or replacement without relying on an attempt identifier. Keep unknown progress unavailable rather than showing zero. Show the replacement commit scope when it differs. Explain that replacement retains prior history and may reuse tuicr's saved session. --- internal/cli/cli.go | 8 ++-- internal/cli/recovery_test.go | 54 +++++++++++++++++++++++++++ internal/cli/render.go | 40 +++++++++++++++++++- internal/cli/tuicr_functional_test.go | 5 ++- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 internal/cli/recovery_test.go 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")