Skip to content
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
7 changes: 2 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ go 1.27.3

require (
charm.land/fang/v2 v2.0.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.5
charm.land/log/v2 v2.0.1
github.com/BurntSushi/toml v1.6.0
Expand All @@ -15,7 +14,6 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/term v0.2.2
github.com/creack/pty v1.1.24
github.com/go-git/go-git/v6 v6.0.0-beta.1
github.com/google/go-github/v91 v91.0.0
Expand All @@ -29,8 +27,6 @@ require (
)

require (
charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.5.2 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
Expand All @@ -52,11 +48,12 @@ require (
github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240904165849-e8e43e13f84b // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/charmbracelet/x/xpty v0.1.3 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
Expand Down
8 changes: 0 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0=
Expand Down Expand Up @@ -86,8 +80,6 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:I
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
Expand Down
50 changes: 27 additions & 23 deletions internal/cli/review/activity_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package cli

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -48,34 +48,38 @@ func TestReviewRunningReportsLiveLockNotPersistedAttempt(t *testing.T) {
require.Error(t, err, string(output))
require.Empty(t, git(t, cwd, "branch", "--show-current"))
}
called := false
reviewer := review.Reviewer{
Service: review.Service{Repository: review.Repository{Dir: dir}},
Choose: func(context.Context, *review.Decision) (string, error) {
called = true
check(dir, true)
check(other, false)
// A paused replay must still identify its branch, including in a
// linked worktree, without treating another branch's lock as its own.
pause(dir)
pause(other)
check(dir, true)
check(other, false)
git(t, dir, "rebase", "--abort")
git(t, other, "rebase", "--abort")
return "cancel", nil
},
}
_, err = reviewer.Review(t.Context(), review.ReviewOptions{})
require.NoError(t, err)
require.True(t, called)
// A live review holds the lock for the whole of tuicr, so a paused replay
// must still identify its branch, including in a linked worktree, without
// treating another branch's lock as its own.
started := filepath.Join(t.TempDir(), "started")
released := filepath.Join(t.TempDir(), "released")
t.Setenv("TEST_STARTED", started)
t.Setenv("TEST_RELEASED", released)
installFakeTuicr(t, `printf started > "$TEST_STARTED"
while [ ! -f "$TEST_RELEASED" ]; do sleep 0.05; done`)
reviewer := review.Reviewer{Service: review.Service{Repository: review.Repository{Dir: dir}}}
finished := make(chan error, 1)
go func() {
_, err := reviewer.Review(t.Context(), review.ReviewOptions{})
finished <- err
}()
require.Eventually(t, func() bool {
_, err := os.Stat(started)
return err == nil
}, 5*time.Second, 10*time.Millisecond)
check(dir, true)
check(other, false)
pause(dir)
pause(other)
check(dir, false)
check(dir, true)
check(other, false)
for _, cwd := range []string{dir, other} {
git(t, cwd, "rebase", "--abort")
}
require.NoError(t, os.WriteFile(released, nil, 0o600))
require.NoError(t, <-finished)
check(dir, false)
check(other, false)
// A detached checkout without rebase metadata has no branch to check.
git(t, dir, "checkout", "--detach")
out, terminal, err := executeBinary(t, binary, dir, "status", "--is-reviewing", "--json")
Expand Down
73 changes: 58 additions & 15 deletions internal/cli/review/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func TestJSONErrorsIncludeArgumentAndGitFailures(t *testing.T) {
for _, args := range [][]string{
{"status", "--needs-review", "--json"},
{"approve", "--json"},
{"commits", "--recover", "--replace", "--json"},
{"commits", "--unknown", "--json"},
{"not-a-command", "--json"},
{"--json", "status", "--needs-review", "--unknown"},
} {
Expand All @@ -126,16 +126,22 @@ func TestReviewBoundsReachAutomaticSelection(t *testing.T) {
state.Attempts = append(state.Attempts, review.Attempt{ID: "bounded", Stack: view.Stacks[1], Status: "incomplete", StartedAt: "2026-01-01T00:00:00Z"})
return nil
}))
log := filepath.Join(t.TempDir(), "launch")
t.Setenv("TEST_LAUNCH_LOG", log)
installFakeTuicr(t, `printf '%s\n' "$4" >> "$TEST_LAUNCH_LOG"`)
out, terminal, err := execute(t, dir, "commits", "--start", "HEAD", "--end", "HEAD", "--json")
require.NoError(t, err)
require.Empty(t, terminal)
var result review.Result
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "decision_required", result.Status)
require.Equal(t, "bounded", result.Decision.AttemptID)
// The bounded stack resumes its captured attempt instead of asking.
require.Equal(t, "cancelled", result.Status)
launch, err := os.ReadFile(log)
require.NoError(t, err)
require.Equal(t, view.Stacks[1].Base+".."+view.Stacks[1].Tip+"\n", string(launch))
}

func TestReviewAliasProducesDecisionWithoutPromptOrStateChanges(t *testing.T) {
func TestReviewAliasResumesTheInterruptedAttempt(t *testing.T) {
dir := featureRepo(t)
view, err := (review.Repository{Dir: dir}).Snapshot(t.Context())
require.NoError(t, err)
Expand All @@ -144,23 +150,19 @@ func TestReviewAliasProducesDecisionWithoutPromptOrStateChanges(t *testing.T) {
state.Attempts = append(state.Attempts, review.Attempt{ID: "interrupted", Stack: view.Stacks[0], Status: "incomplete", StartedAt: "2026-01-01T00:00:00Z"})
return nil
}))
before, err := os.ReadFile(view.Path)
require.NoError(t, err)
log := filepath.Join(t.TempDir(), "launch")
t.Setenv("TEST_LAUNCH_LOG", log)
installFakeTuicr(t, `printf '%s\n' "$4" >> "$TEST_LAUNCH_LOG"`)
out, terminal, err := execute(t, dir, "challenge", "--json")
require.NoError(t, err)
require.Empty(t, terminal)
var result review.Result
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "decision_required", result.Status)
require.Equal(t, "cancelled", result.Status)
require.Equal(t, "commits", result.Command)
require.NotNil(t, result.Decision)
require.Equal(t, []string{"recover", "replace", "cancel"}, result.Decision.Choices)
after, err := os.ReadFile(view.Path)
launch, err := os.ReadFile(log)
require.NoError(t, err)
require.Equal(t, before, after)
out, _, err = execute(t, dir, "run")
require.NoError(t, err)
require.Contains(t, out, "timber review commits --recover")
require.Equal(t, view.Stacks[0].Base+".."+view.Stacks[0].Tip+"\n", string(launch))
}

func TestTerminalRenderingRetainsFeedbackAndRemovesControlSequences(t *testing.T) {
Expand Down Expand Up @@ -227,7 +229,7 @@ func TestTuicrKeepsWorktreeLockedAfterGitReviewDies(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Stat(started); return err == nil }, 5*time.Second, 10*time.Millisecond)
require.NoError(t, cmd.Process.Kill())
require.Error(t, cmd.Wait())
out, _, err := execute(t, dir, "commits", "--replace", "--json")
out, _, err := execute(t, dir, "commits", "--json")
require.ErrorContains(t, err, "worktree is in use")
var result display.ErrorResult
require.NoError(t, json.Unmarshal([]byte(out), &result))
Expand All @@ -239,6 +241,7 @@ func TestHelpAndVersion(t *testing.T) {
require.NoError(t, err)
require.Contains(t, out, "approve")
require.Contains(t, out, "commits")
require.Contains(t, out, "reset")
require.Contains(t, out, "files")
require.Contains(t, out, "--json")
require.NotContains(t, out, "confirm")
Expand All @@ -255,3 +258,43 @@ func TestHelpAndVersion(t *testing.T) {
require.NoError(t, err)
require.Contains(t, out, "test-version")
}

func TestResetClearsSelectedStacks(t *testing.T) {
dir := featureRepo(t)
git(t, dir, "commit", "--allow-empty", "-m", "Second\n\nChange-Id: I"+strings.Repeat("b", 40))
view, err := (review.Repository{Dir: dir}).Snapshot(t.Context())
require.NoError(t, err)
store := review.Store{Path: view.Path}
attempts := func(state *review.State) error {
state.Attempts = nil
for i, stack := range view.Stacks {
state.Attempts = append(state.Attempts, review.Attempt{ID: stack.Key, Stack: stack, Status: "approved", StartedAt: "2026-01-01T00:00:00Z", ApprovalSource: "user-command"})
_ = i
}
return nil
}
require.NoError(t, store.Update(attempts))
out, _, err := execute(t, dir, "reset", "HEAD", "--json")
require.NoError(t, err)
var result review.Result
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "reset", result.Status)
state, err := store.Load()
require.NoError(t, err)
require.Len(t, state.Attempts, 1, "reset clears only the selected stack")
require.Equal(t, view.Stacks[0].Key, state.Attempts[0].ID)

require.NoError(t, store.Update(attempts))
out, _, err = execute(t, dir, "reset", "--json")
require.NoError(t, err)
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "reset", result.Status)
state, err = store.Load()
require.NoError(t, err)
require.Empty(t, state.Attempts, "reset without a selector clears the branch")
out, _, err = execute(t, dir, "status", "--needs-review", "--json")
require.NoError(t, err)
var listed review.Result
require.NoError(t, json.Unmarshal([]byte(out), &listed))
require.Equal(t, "pending", listed.Status)
}
24 changes: 1 addition & 23 deletions internal/cli/review/commits/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ package commits

import (
"context"
"errors"
"io"

"charm.land/huh/v2"
"github.com/spf13/cobra"

"github.com/nnutter/timber/internal/cli/review/display"
Expand All @@ -23,8 +21,6 @@ type Command struct {
Start string
End string
Session string
Recover bool
Replace bool
}

// RunE runs the commits review workflow.
Expand All @@ -34,12 +30,6 @@ func (c *Command) RunE(cmd *cobra.Command, args []string) error {
options.Stack = args[0]
c.Stack = args[0]
}
if c.Recover {
options.Choice = "recover"
}
if c.Replace {
options.Choice = "replace"
}
return c.runReview(cmd.Context(), options, cmd)
}

Expand All @@ -53,15 +43,6 @@ func (c *Command) runReview(ctx context.Context, options review.ReviewOptions, c
return err
}
runner := review.Reviewer{Service: c.Service, Reader: reader, Input: c.Input, Terminal: c.Terminal}
if !asJSON && canPrompt(c.Input, c.Terminal) {
runner.Choose = func(ctx context.Context, decision *review.Decision) (string, error) {
choice, err := promptChoice(ctx, c.Input, c.Terminal, decision)
if errors.Is(err, huh.ErrUserAborted) {
return "cancel", nil
}
return choice, err
}
}
result, err := runner.Review(ctx, options)
if err != nil {
return err
Expand All @@ -73,13 +54,10 @@ func (c *Command) runReview(ctx context.Context, options review.ReviewOptions, c
// with Install.
func New(svc review.Service, in io.Reader, out, term io.Writer) *cobra.Command {
c := &Command{Service: svc, Input: in, Output: out, Terminal: term}
cmd := &cobra.Command{Use: "commits [<commit_stack>]", Aliases: []string{"c", "run", "challenge"}, Short: "Review eligible stacks, or select one stack", Long: "Run tuicr in a stable detached worktree. Without a stack argument, review each\neligible stack in oldest-first order. A commit reference selects one stack. After\napproval or feedback, continue through the selected stacks. With --start or --end,\nlimit selection to the bounded range. Each eligible stack opens once per\ninvocation. Incomplete reviews, cancellation, required decisions, and errors stop\nthe command. Existing interrupted or feedback attempts require an explicit\nrecovery or replacement choice.", Args: cobra.MaximumNArgs(1), RunE: c.RunE}
cmd := &cobra.Command{Use: "commits [<commit_stack>]", Aliases: []string{"c", "run", "challenge"}, Short: "Review eligible stacks, or select one stack", Long: "Run tuicr in a stable detached worktree. Without a stack argument, review each\neligible stack in oldest-first order. A commit reference selects one stack. After\napproval or feedback, continue through the selected stacks. With --start or --end,\nlimit selection to the bounded range. Each eligible stack opens once per\ninvocation. A review resumes the attempt that captured the same stack, and it\ncarries the saved review of an unchanged patch onto rewritten commits. Incomplete\nreviews and errors stop the command.", Args: cobra.MaximumNArgs(1), RunE: c.RunE}
cmd.Flags().StringVar(&c.Start, "start", "", "Review from the stack containing this commit (inclusive)")
cmd.Flags().StringVar(&c.End, "end", "", "Review through the stack containing this commit (inclusive)")
cmd.Flags().BoolVar(&c.Recover, "recover", false, "Reopen the captured attempt in tuicr, preserving saved progress")
cmd.Flags().BoolVar(&c.Replace, "replace", false, "Launch a new attempt, retaining previous review history")
cmd.Flags().StringVar(&c.Session, "session", "", "Select an exact saved session ID, slug, or indexed session path")
cmd.MarkFlagsMutuallyExclusive("recover", "replace")
return cmd
}

Expand Down
52 changes: 0 additions & 52 deletions internal/cli/review/commits/prompt.go

This file was deleted.

Loading
Loading