Skip to content
This repository was archived by the owner on Oct 28, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/recovery_test.go
Original file line number Diff line number Diff line change
@@ -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: &current,
}}
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 <commit>.")
}
require.Contains(t, text, "Replacement keeps the prior attempt in history. Tuicr may reuse its saved session.")
require.NotContains(t, text, "internal-id")
})
}
}
40 changes: 39 additions & 1 deletion internal/cli/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <commit>.")
}
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 {
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/tuicr_functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
17 changes: 15 additions & 2 deletions internal/review/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions internal/review/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions internal/review/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading