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
6 changes: 3 additions & 3 deletions .config/git.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ source = "git@github.com:nnutter/go-template.git"
[answers]
copyright_holder = "Nathan Nutter"
copyright_year = "2026"
formula_name = "umpire"
formula_name = "git-review"
go_version = "1.27.1"
module_path = "github.com/nnutter/umpire"
project_name = "umpire"
module_path = "github.com/nnutter/git-review"
project_name = "git-review"
tap_owner = "nnutter"
tap_repository = "homebrew-tap"
with_homebrew = true
2 changes: 1 addition & 1 deletion .github/workflows/update-tap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
FORMULA_NAME: umpire
FORMULA_NAME: git-review
TAP_OWNER: nnutter
TAP_REPOSITORY: homebrew-tap
steps:
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Commit Review

Umpire tracks user review of changes on a feature branch.
git-review tracks user review of changes on a feature branch.

## Language

Expand Down
165 changes: 97 additions & 68 deletions README.md

Large diffs are not rendered by default.

File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/nnutter/umpire
module github.com/nnutter/git-review

go 1.27.1

Expand Down
10 changes: 5 additions & 5 deletions internal/cli/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import (

"github.com/stretchr/testify/require"

"github.com/nnutter/umpire/internal/review"
"github.com/nnutter/git-review/internal/review"
)

func TestReviewRunningReportsLiveLockNotPersistedAttempt(t *testing.T) {
binary := buildUmpire(t)
binary := buildGitReview(t)
dir := featureRepo(t)
path, err := review.WorktreePath(dir)
require.NoError(t, err)
check := func(cwd string, running bool) {
t.Helper()
out, terminal, err := executeBinary(t, binary, cwd, "review-running", "--json")
out, terminal, err := executeBinary(t, binary, cwd, "running", "--json")
require.NoError(t, err)
require.Empty(t, terminal)
want := `{"version":1,"running":false}`
Expand All @@ -38,7 +38,7 @@ func TestReviewRunningReportsLiveLockNotPersistedAttempt(t *testing.T) {
}))
check(dir, false)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
lock, err := os.OpenFile(path+".umpire.lock", os.O_CREATE|os.O_RDWR, 0o600)
lock, err := os.OpenFile(path+".git-review.lock", os.O_CREATE|os.O_RDWR, 0o600)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, lock.Close()) })
require.NoError(t, syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
Expand All @@ -51,7 +51,7 @@ func TestReviewRunningReportsLiveLockNotPersistedAttempt(t *testing.T) {
}

func TestReviewRunningOutsideRepository(t *testing.T) {
out, terminal, err := execute(t, t.TempDir(), "review-running", "--json")
out, terminal, err := execute(t, t.TempDir(), "running", "--json")
require.Error(t, err)
require.Empty(t, terminal)
require.Contains(t, out, `"status":"error"`)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import (
"github.com/stretchr/testify/require"
)

func buildUmpire(t *testing.T) string {
func buildGitReview(t *testing.T) string {
t.Helper()
binary := filepath.Join(t.TempDir(), "umpire")
binary := filepath.Join(t.TempDir(), "git-review")
cmd := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "../..")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
Expand Down
32 changes: 12 additions & 20 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package cli exposes Umpire's review workflow through Cobra and Fang.
// Package cli exposes git-review's review workflow through Cobra and Fang.
package cli

import (
Expand All @@ -16,7 +16,7 @@ import (
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"

"github.com/nnutter/umpire/internal/review"
"github.com/nnutter/git-review/internal/review"
)

// Execute runs a command with explicit process streams and checkout location.
Expand Down Expand Up @@ -51,15 +51,8 @@ type application struct {
}

func (a *application) command() *cobra.Command {
root := &cobra.Command{Use: "umpire", Short: "Review feature-branch commit stacks with tuicr", Long: "Umpire tracks review of exact commit stacks against the configured Git upstream.\nApproval never transfers to rewritten commits or added fixups."}
root := &cobra.Command{Use: "git-review", Short: "Review feature-branch commit stacks with tuicr", Long: "git-review tracks review of exact commit stacks against the configured Git upstream.\nApproval never transfers to rewritten commits or added fixups."}
root.PersistentFlags().BoolVar(&a.json, "json", a.json, "Emit a versioned JSON result without interactive prompts")
needsReview := &cobra.Command{Use: "needs-review", Short: "Show unresolved stacks and active review attempts", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
result, err := a.service.List(cmd.Context())
if err != nil {
return err
}
return a.write(result)
}}
feedback := &cobra.Command{Use: "feedback", Short: "Read current and historical saved feedback without launching tuicr", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
result, err := a.service.Feedback(cmd.Context())
if err != nil {
Expand All @@ -78,17 +71,16 @@ func (a *application) command() *cobra.Command {
}
return a.write(result)
}}
status := &cobra.Command{Use: "status", Short: "Show commit counts in each review state", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
counts, err := a.service.Status(cmd.Context())
var onlyNeedsReview bool
status := &cobra.Command{Use: "status", Short: "Show all feature stacks and their review states", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
result, err := a.service.Status(cmd.Context(), onlyNeedsReview)
if err != nil {
return err
}
if a.json {
return writeJSON(a.output, counts)
}
return renderStatus(a.output, counts)
return a.write(result)
}}
activity := &cobra.Command{Use: "review-running", Short: "Check whether a review process holds the shared worktree lock", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
status.Flags().BoolVar(&onlyNeedsReview, "needs-review", false, "Show unresolved stacks and active review attempts")
activity := &cobra.Command{Use: "running", Short: "Check whether a review process holds the shared worktree lock", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
running, err := a.service.ReviewRunning(cmd.Context())
if err != nil {
return err
Expand All @@ -102,14 +94,14 @@ func (a *application) command() *cobra.Command {
_, err = fmt.Fprintf(a.output, "Review running: %t\n", running)
return err
}}
root.AddCommand(needsReview, feedback, approve, status, activity, a.reviewCommand())
root.AddCommand(feedback, approve, status, activity, a.runCommand(), a.logCommand())
return root
}

func (a *application) reviewCommand() *cobra.Command {
func (a *application) runCommand() *cobra.Command {
var recoverReview, replaceReview bool
var session, start, end string
cmd := &cobra.Command{Use: "review [<commit_stack>]", Aliases: []string{"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: func(cmd *cobra.Command, args []string) error {
cmd := &cobra.Command{Use: "run [<commit_stack>]", Aliases: []string{"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: func(cmd *cobra.Command, args []string) error {
options := review.ReviewOptions{Session: session, Start: start, End: end}
if len(args) > 0 {
options.Stack = args[0]
Expand Down
57 changes: 36 additions & 21 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

"github.com/stretchr/testify/require"

"github.com/nnutter/umpire/internal/review"
"github.com/nnutter/git-review/internal/review"
)

func git(t *testing.T, dir string, args ...string) string {
Expand Down Expand Up @@ -46,21 +46,29 @@ func execute(t *testing.T, dir string, args ...string) (string, string, error) {
}

func TestCommandsShareStructuredResults(t *testing.T) {
binary := buildUmpire(t)
binary := buildGitReview(t)
dir := featureRepo(t)
for _, args := range [][]string{{"needs-review", "--json"}, {"--json", "needs-review"}} {
out, terminal, err := executeBinary(t, binary, dir, args...)
t.Setenv("PATH", filepath.Dir(binary)+string(os.PathListSeparator)+os.Getenv("PATH"))
for _, tc := range []struct {
binary string
args []string
}{
{binary: binary, args: []string{"status", "--needs-review", "--json"}},
{binary: binary, args: []string{"--json", "status", "--needs-review"}},
{binary: "git", args: []string{"review", "status", "--needs-review", "--json"}},
} {
out, terminal, err := executeBinary(t, tc.binary, dir, tc.args...)
require.NoError(t, err)
require.Empty(t, terminal)
require.Equal(t, 1, strings.Count(out, "\n"))
var result review.Result
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "needs-review", result.Command)
require.Equal(t, "status", result.Command)
require.Equal(t, "needs_review", result.Entries[0].Status)
require.Len(t, result.Entries[0].Stack.Commits[0], 40)
require.NotContains(t, out, "\x1b")
}
out, terminal, err := executeBinary(t, binary, dir, "needs-review")
out, terminal, err := executeBinary(t, binary, dir, "status", "--needs-review")
require.NoError(t, err)
require.Empty(t, terminal)
require.Contains(t, out, "Needs review")
Expand All @@ -75,7 +83,7 @@ func TestCommandsShareStructuredResults(t *testing.T) {
require.Equal(t, "approved", approved.Status)
require.Equal(t, "approve", approved.Command)
require.Equal(t, "user-command", approved.Entries[0].Attempt.ApprovalSource)
out, _, err = executeBinary(t, binary, dir, "needs-review", "--json")
out, _, err = executeBinary(t, binary, dir, "status", "--needs-review", "--json")
require.NoError(t, err)
var listed review.Result
require.NoError(t, json.Unmarshal([]byte(out), &listed))
Expand All @@ -84,15 +92,15 @@ func TestCommandsShareStructuredResults(t *testing.T) {
}

func TestJSONErrorsIncludeArgumentAndGitFailures(t *testing.T) {
binary := buildUmpire(t)
binary := buildGitReview(t)
dir := featureRepo(t)
git(t, dir, "branch", "--unset-upstream")
for _, args := range [][]string{
{"needs-review", "--json"},
{"status", "--needs-review", "--json"},
{"approve", "--json"},
{"review", "--recover", "--replace", "--json"},
{"run", "--recover", "--replace", "--json"},
{"not-a-command", "--json"},
{"--json", "needs-review", "--unknown"},
{"--json", "status", "--needs-review", "--unknown"},
} {
out, terminal, err := executeBinary(t, binary, dir, args...)
var exit *exec.ExitError
Expand All @@ -116,7 +124,7 @@ 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
}))
out, terminal, err := execute(t, dir, "review", "--start", "HEAD", "--end", "HEAD", "--json")
out, terminal, err := execute(t, dir, "run", "--start", "HEAD", "--end", "HEAD", "--json")
require.NoError(t, err)
require.Empty(t, terminal)
var result review.Result
Expand All @@ -142,14 +150,15 @@ func TestReviewAliasProducesDecisionWithoutPromptOrStateChanges(t *testing.T) {
var result review.Result
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "decision_required", result.Status)
require.Equal(t, "run", result.Command)
require.NotNil(t, result.Decision)
require.Equal(t, []string{"recover", "replace", "cancel"}, result.Decision.Choices)
after, err := os.ReadFile(view.Path)
require.NoError(t, err)
require.Equal(t, before, after)
out, _, err = execute(t, dir, "review")
out, _, err = execute(t, dir, "run")
require.NoError(t, err)
require.Contains(t, out, "umpire review --recover")
require.Contains(t, out, "git-review run --recover")
}

func TestTerminalRenderingRetainsFeedbackAndRemovesControlSequences(t *testing.T) {
Expand All @@ -159,7 +168,7 @@ func TestTerminalRenderingRetainsFeedbackAndRemovesControlSequences(t *testing.T
attempt := review.Attempt{ID: "feedback", Stack: view.Stacks[0], Status: "feedback", StartedAt: "2026-01-01T00:00:00Z"}
attempt.Review = jsontext.Value(`{"id":"saved","version":"1.3","reviewed":1,"files":2,"complete":false,"comments":[{"content":"Revise validation\u001b[31m","location":"line","path":"main.go","stored_line":"12"}],"sessionNotes":"Explain the change"}`)
require.NoError(t, (review.Store{Path: view.Path}).Update(func(state *review.State) error { state.Attempts = append(state.Attempts, attempt); return nil }))
out, _, err := execute(t, dir, "needs-review")
out, _, err := execute(t, dir, "status", "--needs-review")
require.NoError(t, err)
require.Contains(t, out, "Feedback")
require.Contains(t, out, "main.go:12: Revise validation")
Expand All @@ -174,7 +183,7 @@ func TestReviewKeepsTuicrOutputOffJSONStdout(t *testing.T) {
bin := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(bin, "tuicr"), []byte("#!/bin/sh\nprintf 'tuicr terminal output\\n' >&2\nprintf 'exported feedback\\n'\n"), 0o700))
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
out, terminal, err := execute(t, dir, "review", "--json")
out, terminal, err := execute(t, dir, "run", "--json")
require.NoError(t, err)
require.Equal(t, "tuicr terminal output\n", terminal)
var result review.Result
Expand All @@ -184,12 +193,12 @@ func TestReviewKeepsTuicrOutputOffJSONStdout(t *testing.T) {
require.NotContains(t, out, "exported feedback")
}

func TestTuicrKeepsWorktreeLockedAfterUmpireDies(t *testing.T) {
func TestTuicrKeepsWorktreeLockedAfterGitReviewDies(t *testing.T) {
dir := featureRepo(t)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
bin := t.TempDir()
binary := filepath.Join(bin, "umpire")
binary := filepath.Join(bin, "git-review")
build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "../..")
buildOut, err := build.CombinedOutput()
require.NoError(t, err, "%s", buildOut)
Expand All @@ -200,7 +209,7 @@ func TestTuicrKeepsWorktreeLockedAfterUmpireDies(t *testing.T) {
script := "#!/bin/sh\nset -eu\nprintf started > \"$TEST_STARTED\"\nwhile [ ! -f \"$TEST_EXIT\" ]; do sleep 0.05; done\nprintf finished > \"$TEST_STARTED\"\n"
require.NoError(t, os.WriteFile(filepath.Join(bin, "tuicr"), []byte(script), 0o700))
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
cmd := exec.CommandContext(t.Context(), binary, "review", "--json")
cmd := exec.CommandContext(t.Context(), binary, "run", "--json")
cmd.Dir = dir
require.NoError(t, cmd.Start())
defer func() {
Expand All @@ -216,7 +225,7 @@ func TestTuicrKeepsWorktreeLockedAfterUmpireDies(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, "review", "--replace", "--json")
out, _, err := execute(t, dir, "run", "--replace", "--json")
require.ErrorContains(t, err, "worktree is in use")
var result errorResult
require.NoError(t, json.Unmarshal([]byte(out), &result))
Expand All @@ -227,10 +236,16 @@ func TestHelpAndVersion(t *testing.T) {
out, _, err := execute(t, t.TempDir(), "--help")
require.NoError(t, err)
require.Contains(t, out, "approve")
require.Contains(t, out, "review")
require.Contains(t, out, "run")
require.Contains(t, out, "running")
require.Contains(t, out, "--json")
require.NotContains(t, out, "confirm")
require.NotContains(t, out, "challenge")
for _, name := range []string{"needs-review", "review", "review-running"} {
out, _, err = execute(t, t.TempDir(), name)
require.ErrorContains(t, err, "unknown command")
require.Empty(t, out)
}
out, _, err = execute(t, t.TempDir(), "--version")
require.NoError(t, err)
require.Contains(t, out, "test-version")
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/feedback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (

"github.com/stretchr/testify/require"

"github.com/nnutter/umpire/internal/review"
"github.com/nnutter/git-review/internal/review"
)

func TestFeedbackReadsPersistedCurrentAndHistoricalNotes(t *testing.T) {
binary := buildUmpire(t)
binary := buildGitReview(t)
dir := featureRepo(t)
repository := review.Repository{Dir: dir}
original, err := repository.Snapshot(t.Context())
Expand Down
46 changes: 46 additions & 0 deletions internal/cli/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cli

import (
"fmt"
"os/exec"

"github.com/spf13/cobra"
)

func (a *application) logCommand() *cobra.Command {
var patch, ignoreAllSpace, stat bool
cmd := &cobra.Command{
Use: "log",
Short: "Show commits after the upstream in oldest-first order",
Long: "Run git log @{u}..HEAD --reverse with optional patches, diff statistics, and whitespace filtering.\nOutput uses Git's format and pager, not git-review's review state.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if a.json {
return fmt.Errorf("log does not support --json")
}
args := []string{"log", "@{u}..HEAD", "--reverse"}
if patch {
args = append(args, "-p")
}
if ignoreAllSpace {
args = append(args, "-w")
}
if stat {
args = append(args, "--stat")
}
git := exec.CommandContext(cmd.Context(), "git", args...)
git.Dir = a.service.Repository.Dir
git.Stdin = a.input
git.Stdout = a.output
git.Stderr = a.terminal
if err := git.Run(); err != nil {
return fmt.Errorf("git log: %w", err)
}
return nil
},
}
cmd.Flags().BoolVarP(&patch, "patch", "p", false, "Show the patch for each commit")
cmd.Flags().BoolVarP(&ignoreAllSpace, "ignore-all-space", "w", false, "Ignore whitespace changes in patches")
cmd.Flags().BoolVar(&stat, "stat", false, "Show diff statistics for each commit")
return cmd
}
Loading
Loading