From 3c2baefb3404b550bf1e5b5af27fcdc31540a473 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 11:07:49 +0000 Subject: [PATCH 01/13] feat(ui): live per-repo rain status in --rain TUI Show queued/running/retrying/timed out/frozen/failed/done states during the rain phase. Keys: s skip selected running repo, a abort all (partial summary). Highlight repos running past fetch_timeout_sec. Co-authored-by: Ben Schellenberger --- AGENTS.md | 2 + README.md | 5 +- cmd/rain_coordinator.go | 56 ++++++++ cmd/rain_parallel_test.go | 2 +- cmd/rain_tui_run.go | 42 ++++++ cmd/rain_workers.go | 96 ++++++++++++- cmd/root.go | 8 +- internal/git/network_retry.go | 5 + internal/git/network_retry_test.go | 40 ------ internal/git/rain.go | 3 + internal/rainexec/events.go | 28 ++++ internal/ui/rain_run_model.go | 214 +++++++++++++++++++++++++++++ internal/ui/rain_run_model_test.go | 76 ++++++++++ 13 files changed, 525 insertions(+), 52 deletions(-) create mode 100644 cmd/rain_coordinator.go create mode 100644 cmd/rain_tui_run.go create mode 100644 internal/rainexec/events.go create mode 100644 internal/ui/rain_run_model.go create mode 100644 internal/ui/rain_run_model_test.go diff --git a/AGENTS.md b/AGENTS.md index ceae3ed..de2dbf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,4 +37,6 @@ These notes apply to humans and to automated agents (including Cloud Agents). - **Manual end-to-end check:** create a bare remote + a clone under a scan dir, advance the remote, then `./git-rain --sync --path ` fast-forwards the local branch. Requires `git user.name`/`user.email` to be set (already configured here). - **Network fetch timeouts:** `global.fetch_timeout_sec` (default `120`, `0` disables), `--fetch-timeout`, and `GIT_RAIN_GLOBAL_FETCH_TIMEOUT_SEC` bound per-repo `git fetch` via `RunNetworkGit`. Stalled remotes should surface `timed out after Ns` and not block the run summary. - **Interrupts:** live runs use `signal.NotifyContext` (SIGINT/SIGTERM) so Ctrl-C cancels in-flight network git and prints a partial summary with exit code `1`. +- **`--rain` post-picker:** repos run through the same `fetch_workers` pool as the default CLI; the rain-phase TUI shows per-repo status (`s` skip running repo, `a` abort all). +- **Fetch retries:** `fetch_retry_*` settings retry only transient network failures (never auth / 404 / prompt-disabled). diff --git a/README.md b/README.md index 37a84a6..24a0391 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,9 @@ scan_depth = 5 # max directory depth (default in app: 10) scan_workers = 8 # parallel scan workers fetch_workers = 4 # parallel per-repo operations (default in app: 4) fetch_timeout_sec = 120 # per-fetch network deadline (0 = no timeout) +fetch_retry_max_attempts = 2 # retry transient network failures only +fetch_retry_initial_backoff_sec = 2 +fetch_retry_max_backoff_sec = 30 risky_mode = false # allow destructive realignment on full hydration path branch_mode = "mainline" # full hydration: mainline | checked-out | all-local | all-branches fetch_prune = false # pass --prune on fetch when true (default off; see README warning) @@ -394,7 +397,7 @@ rain_panel_size = "comfortable" ## Interactive TUI -`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, selected repos run through the same **bounded `fetch_workers` pool** as the default CLI (so one stalled remote does not block the rest). The tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel. +`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, selected repos run through the same **bounded `fetch_workers` pool** as the default CLI (so one stalled remote does not block the rest). The tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel. After you confirm repos, the **rain phase** shows live per-repo status (queued, running, retrying, timed out, frozen, failed, done). Use **`s`** to skip the selected running repo and **`a`** to abort the whole rain (partial summary, non-zero exit). Status strip and optional log panel are driven from structured session events. Confirming selection still exits TUI and runs fetch/sync work in normal CLI output. diff --git a/cmd/rain_coordinator.go b/cmd/rain_coordinator.go new file mode 100644 index 0000000..e8e4e2c --- /dev/null +++ b/cmd/rain_coordinator.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "context" + "sync" +) + +// rainCoordinator provides per-repo cancel contexts under a shared run context. +type rainCoordinator struct { + parent context.Context + mu sync.Mutex + repos map[string]context.Context + cancel map[string]context.CancelFunc +} + +func newRainCoordinator(parent context.Context, repoPaths []string) *rainCoordinator { + if parent == nil { + parent = context.Background() + } + c := &rainCoordinator{ + parent: parent, + repos: make(map[string]context.Context, len(repoPaths)), + cancel: make(map[string]context.CancelFunc, len(repoPaths)), + } + for _, p := range repoPaths { + ctx, cancel := context.WithCancel(parent) + c.repos[p] = ctx + c.cancel[p] = cancel + } + return c +} + +func (c *rainCoordinator) ContextFor(repoPath string) context.Context { + c.mu.Lock() + defer c.mu.Unlock() + if ctx, ok := c.repos[repoPath]; ok { + return ctx + } + return c.parent +} + +func (c *rainCoordinator) SkipRepo(repoPath string) { + c.mu.Lock() + defer c.mu.Unlock() + if cancel, ok := c.cancel[repoPath]; ok { + cancel() + } +} + +func (c *rainCoordinator) AbortAll() { + c.mu.Lock() + defer c.mu.Unlock() + for _, cancel := range c.cancel { + cancel() + } +} diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go index f9c6dd3..5e0b15f 100644 --- a/cmd/rain_parallel_test.go +++ b/cmd/rain_parallel_test.go @@ -46,7 +46,7 @@ func TestRunRainOnRepos_ParallelStallDoesNotBlockHealthy(t *testing.T) { defer func() { os.Stdout = oldStdout }() start := time.Now() - err := runRainOnRepos(®istry.Registry{}, repos, rainOpts, false, 2) + err := runRainOnRepos(®istry.Registry{}, repos, rainOpts, false, 2) // CLI path (parallel stdout) elapsed := time.Since(start) _ = w.Close() diff --git a/cmd/rain_tui_run.go b/cmd/rain_tui_run.go new file mode 100644 index 0000000..405e35a --- /dev/null +++ b/cmd/rain_tui_run.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "github.com/git-rain/git-rain/internal/config" + "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/rainexec" + "github.com/git-rain/git-rain/internal/registry" + "github.com/git-rain/git-rain/internal/ui" +) + +func runRainOnReposTUI(cfg *config.Config, reg *registry.Registry, repos []git.Repository, opts git.RainOptions, fullSync bool, abortRun func()) error { + if len(repos) == 0 { + return nil + } + + paths := make([]string, len(repos)) + for i, r := range repos { + paths[i] = r.Path + } + coord := newRainCoordinator(opts.RunCtx, paths) + events := make(chan rainexec.Event, len(repos)*8) + done := make(chan rainTotals, 1) + + go func() { + totals := runRainReposParallel(reg, repos, opts, fullSync, cfg.Global.FetchWorkers, events, coord, true) + close(events) + done <- totals + }() + + ctrl := ui.RainControls{ + Skip: coord.SkipRepo, + Abort: func() { + coord.AbortAll() + if abortRun != nil { + abortRun() + } + }, + } + _ = ui.RunRainProgressTUI(repos, events, ctrl, cfg.Global.FetchTimeoutSec) + totals := <-done + return printRainSummary(totals, opts.RunCtx) +} diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go index 8a61282..7091092 100644 --- a/cmd/rain_workers.go +++ b/cmd/rain_workers.go @@ -6,18 +6,36 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/rainexec" "github.com/git-rain/git-rain/internal/registry" ) +func emitRainEvent(ch chan<- rainexec.Event, e rainexec.Event) { + if ch == nil { + return + } + ch <- e +} + // runRainReposParallel runs fetch/rain for repos using the same bounded worker -// pool as the default streaming CLI path. -func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOpts git.RainOptions, fullSync bool, fetchWorkers int) rainTotals { +// pool as the default streaming CLI path. When progress is non-nil, repo status +// events are emitted; when quiet is true, per-repo CLI blocks are suppressed. +func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOpts git.RainOptions, fullSync bool, fetchWorkers int, progress chan rainexec.Event, coord *rainCoordinator, quiet bool) rainTotals { if len(repos) == 0 { return rainTotals{} } + for _, repo := range repos { + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: rainexec.StateQueued, + }) + } + workers := fetchWorkerCount(fetchWorkers) repoChan := make(chan git.Repository, len(repos)) for _, repo := range repos { @@ -45,12 +63,78 @@ func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOp if runCtx != nil && runCtx.Err() != nil { break } + repoCtx := runCtx + if coord != nil { + repoCtx = coord.ContextFor(repo.Path) + } + if repoCtx != nil && repoCtx.Err() != nil { + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: rainexec.StateSkipped, + Detail: "skipped", + }) + continue + } + + started := time.Now() + maxAttempts := rainOpts.FetchRetryMaxAttempts + if maxAttempts <= 0 { + maxAttempts = git.DefaultFetchRetryMaxAttempts + } + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: rainexec.StateRunning, + Attempt: 1, + MaxAttempts: maxAttempts, + StartedAt: started, + }) + + repoOpts := rainOpts + repoOpts.RunCtx = repoCtx + repoOpts.OnFetchAttempt = func(attempt, max int) { + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: rainexec.StateRetrying, + Attempt: attempt, + MaxAttempts: max, + StartedAt: started, + }) + } + current := int(atomic.AddInt64(&seq, 1)) var buf strings.Builder - delta := runRainOnRepo(reg, repo, rainOpts, fullSync, current, int(total), &buf) - printMu.Lock() - _, _ = io.WriteString(os.Stdout, buf.String()) - printMu.Unlock() + delta := runRainOnRepo(reg, repo, repoOpts, fullSync, current, int(total), &buf) + + state := rainexec.StateDone + detail := "" + switch { + case repoCtx != nil && repoCtx.Err() != nil: + state = rainexec.StateSkipped + detail = "skipped" + case delta.failed > 0: + state = rainexec.StateFailed + case delta.frozen > 0: + state = rainexec.StateFrozen + if strings.Contains(buf.String(), "timed out") { + state = rainexec.StateTimedOut + } + } + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: state, + StartedAt: started, + Detail: detail, + }) + + if !quiet { + printMu.Lock() + _, _ = io.WriteString(os.Stdout, buf.String()) + printMu.Unlock() + } atomic.AddInt64(&updated, int64(delta.updated)) atomic.AddInt64(&skipped, int64(delta.skipped)) atomic.AddInt64(&frozen, int64(delta.frozen)) diff --git a/cmd/root.go b/cmd/root.go index 17c77bf..90ca8bb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -240,7 +240,7 @@ func runRain(_ *cobra.Command, _ []string) error { rainOpts.RunCtx = runCtx if rainRain { - return runRainTUIStream(cfg, reg, regPath, opts, rainOpts, fullSync) + return runRainTUIStream(cfg, reg, regPath, opts, rainOpts, fullSync, stopRun) } return runRainDefaultStream(cfg, reg, regPath, opts, rainOpts, fullSync) @@ -514,7 +514,7 @@ func runDryRun(scanOpts git.ScanOptions, rainOpts git.RainOptions, fullSync bool // runRainTUIStream launches the interactive TUI repo picker (streaming mode). // The filesystem scan runs concurrently; repos stream into the picker as they // are discovered. After confirmation, the same default or --sync behavior runs. -func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, rainOpts git.RainOptions, fullSync bool) error { +func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, rainOpts git.RainOptions, fullSync bool, abortRun func()) error { scanParent := rainOpts.RunCtx if scanParent == nil { scanParent = context.Background() @@ -614,7 +614,7 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string } fmt.Println() - return runRainOnRepos(reg, selected, rainOpts, fullSync, cfg.Global.FetchWorkers) + return runRainOnReposTUI(cfg, reg, selected, rainOpts, fullSync, abortRun) } // fetchWorkerCount returns the per-run parallel fetch worker count, clamped @@ -883,7 +883,7 @@ func runRainOnRepos(reg *registry.Registry, repos []git.Repository, opts git.Rai } fmt.Println() - totals := runRainReposParallel(reg, repos, opts, fullSync, fetchWorkers) + totals := runRainReposParallel(reg, repos, opts, fullSync, fetchWorkers, nil, nil, false) return printRainSummary(totals, opts.RunCtx) } diff --git a/internal/git/network_retry.go b/internal/git/network_retry.go index ba8f918..e2bb3e9 100644 --- a/internal/git/network_retry.go +++ b/internal/git/network_retry.go @@ -20,6 +20,7 @@ type NetworkGitParams struct { MaxAttempts int InitialBackoffSec int MaxBackoffSec int + OnRetry func(attempt int, maxAttempts int) } // NetworkGitParamsFromRain maps RainOptions into network git execution params. @@ -33,6 +34,7 @@ func NetworkGitParamsFromRain(opts RainOptions) NetworkGitParams { MaxAttempts: maxAttempts, InitialBackoffSec: opts.FetchRetryInitialBackoffSec, MaxBackoffSec: opts.FetchRetryMaxBackoffSec, + OnRetry: opts.OnFetchAttempt, } } @@ -77,6 +79,9 @@ func runNetworkGitWithRetry(parent context.Context, cmd *exec.Cmd, params Networ if attemptNum >= maxAttempts || !isTransientNetworkFailure(out, err) { return out, err } + if params.OnRetry != nil { + params.OnRetry(attemptNum+1, maxAttempts) + } if !sleepWithContext(parent, backoff) { return lastOut, parent.Err() } diff --git a/internal/git/network_retry_test.go b/internal/git/network_retry_test.go index 41920e1..603b4f7 100644 --- a/internal/git/network_retry_test.go +++ b/internal/git/network_retry_test.go @@ -3,7 +3,6 @@ package git import ( "context" "errors" - "net" "net/http" "net/http/httptest" "os/exec" @@ -144,42 +143,3 @@ func TestRunNetworkGitWithRetry_RespectsCancellationDuringBackoff(t *testing.T) t.Fatalf("calls = %d, want 1", calls) } } - -func TestRunNetworkGit_RetriesTCPResetServer(t *testing.T) { - var accepts atomic.Int32 - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - defer ln.Close() - go func() { - for { - c, aerr := ln.Accept() - if aerr != nil { - return - } - if accepts.Add(1) < 2 { - _ = c.Close() - continue - } - _ = c.Close() - } - }() - - stub := func(ctx context.Context, c *exec.Cmd, timeoutSec int) ([]byte, error) { - conn, derr := net.DialTimeout("tcp", ln.Addr().String(), time.Second) - if derr != nil { - return []byte(derr.Error()), derr - } - _ = conn.Close() - if accepts.Load() < 2 { - return []byte("read: connection reset by peer"), errors.New("reset") - } - return nil, nil - } - params := NetworkGitParams{MaxAttempts: 2, InitialBackoffSec: 1} - _, err = runNetworkGitWithRetry(context.Background(), exec.Command("git", "fetch"), params, stub) - if err != nil { - t.Fatalf("expected success on second attempt: %v", err) - } -} diff --git a/internal/git/rain.go b/internal/git/rain.go index 587eba7..eb9cdca 100644 --- a/internal/git/rain.go +++ b/internal/git/rain.go @@ -142,6 +142,9 @@ type RainOptions struct { // RunCtx cancels in-flight network git when the run is interrupted. RunCtx context.Context + + // OnFetchAttempt is called before retry attempts (attempt >= 2). + OnFetchAttempt func(attempt int, maxAttempts int) } const ( diff --git a/internal/rainexec/events.go b/internal/rainexec/events.go new file mode 100644 index 0000000..21dc520 --- /dev/null +++ b/internal/rainexec/events.go @@ -0,0 +1,28 @@ +package rainexec + +import "time" + +// RepoState is the rain-phase status for one repository. +type RepoState string + +const ( + StateQueued RepoState = "queued" + StateRunning RepoState = "running" + StateRetrying RepoState = "retrying" + StateTimedOut RepoState = "timed_out" + StateFrozen RepoState = "frozen" + StateFailed RepoState = "failed" + StateDone RepoState = "done" + StateSkipped RepoState = "skipped" +) + +// Event is a rain-phase progress update for the TUI or tests. +type Event struct { + RepoPath string + RepoName string + State RepoState + Attempt int + MaxAttempts int + StartedAt time.Time + Detail string +} diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go new file mode 100644 index 0000000..c9e5409 --- /dev/null +++ b/internal/ui/rain_run_model.go @@ -0,0 +1,214 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + + "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/rainexec" +) + +// ErrRainAborted is returned when the user aborts the rain phase from the TUI. +var ErrRainAborted = fmt.Errorf("rain aborted") + +// RainControls exposes skip/abort actions for the rain-phase TUI. +type RainControls struct { + Skip func(repoPath string) + Abort func() +} + +type rainRepoRow struct { + path, name string + state rainexec.RepoState + attempt int + maxAttempts int + startedAt time.Time + detail string +} + +type rainRunModel struct { + rows []rainRepoRow + cursor int + timeoutSec int + events <-chan rainexec.Event + ctrl RainControls + aborted bool + width int + height int +} + +type rainEventMsg rainexec.Event + +type rainTickMsg time.Time + +// RunRainProgressTUI shows live per-repo rain status until events is closed. +func RunRainProgressTUI(repos []git.Repository, events <-chan rainexec.Event, ctrl RainControls, timeoutSec int) error { + rows := make([]rainRepoRow, len(repos)) + for i, r := range repos { + rows[i] = rainRepoRow{path: r.Path, name: r.Name, state: rainexec.StateQueued} + } + m := rainRunModel{ + rows: rows, + timeoutSec: timeoutSec, + events: events, + ctrl: ctrl, + } + final, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return err + } + if fm, ok := final.(rainRunModel); ok && fm.aborted { + return ErrRainAborted + } + return nil +} + +func (m rainRunModel) Init() tea.Cmd { + return tea.Batch(listenRainEvent(m.events), tickRain()) +} + +func listenRainEvent(ch <-chan rainexec.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return tea.Quit() + } + return rainEventMsg(e) + } +} + +func tickRain() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return rainTickMsg(t) }) +} + +func (m rainRunModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case rainEventMsg: + m.applyEvent(rainexec.Event(msg)) + return m, listenRainEvent(m.events) + case rainTickMsg: + return m, tickRain() + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + case "s": + if m.ctrl.Skip != nil && m.cursor < len(m.rows) { + row := m.rows[m.cursor] + if row.state == rainexec.StateRunning || row.state == rainexec.StateRetrying { + m.ctrl.Skip(row.path) + } + } + case "a", "ctrl+c": + m.aborted = true + if m.ctrl.Abort != nil { + m.ctrl.Abort() + } + return m, tea.Quit + case "q": + return m, tea.Quit + } + } + return m, nil +} + +func (m *rainRunModel) applyEvent(e rainexec.Event) { + for i := range m.rows { + if m.rows[i].path == e.RepoPath { + m.rows[i].state = e.State + m.rows[i].attempt = e.Attempt + m.rows[i].maxAttempts = e.MaxAttempts + if !e.StartedAt.IsZero() { + m.rows[i].startedAt = e.StartedAt + } + if e.Detail != "" { + m.rows[i].detail = e.Detail + } + return + } + } +} + +func (m rainRunModel) View() string { + var b strings.Builder + title := lipgloss.NewStyle().Bold(true).Render("🌧 Rain in progress") + b.WriteString(title) + b.WriteString("\n") + b.WriteString("↑/↓ select · s skip running repo · a abort all · q close when done\n\n") + + for i, row := range m.rows { + line := m.formatRow(row, i == m.cursor) + b.WriteString(line) + b.WriteString("\n") + } + return b.String() +} + +func (m rainRunModel) formatRow(row rainRepoRow, selected bool) string { + label := row.name + if label == "" { + label = row.path + } + elapsed := "" + overdue := false + if row.state == rainexec.StateRunning || row.state == rainexec.StateRetrying { + if !row.startedAt.IsZero() { + sec := int(time.Since(row.startedAt).Seconds()) + elapsed = fmt.Sprintf(" %ds", sec) + if m.timeoutSec > 0 && sec >= m.timeoutSec { + overdue = true + } + } + } + status := string(row.state) + switch row.state { + case rainexec.StateRetrying: + status = fmt.Sprintf("retrying %d/%d", row.attempt, row.maxAttempts) + case rainexec.StateRunning: + status = "running" + elapsed + case rainexec.StateTimedOut: + status = "timed out" + case rainexec.StateFrozen: + status = "frozen" + case rainexec.StateFailed: + status = "failed" + case rainexec.StateDone: + status = "done" + case rainexec.StateSkipped: + status = "skipped" + case rainexec.StateQueued: + status = "queued" + } + + prefix := " " + if selected { + prefix = "▸ " + } + line := prefix + label + " — " + status + if overdue { + line = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) + } + return runewidth.Truncate(line, max(40, m.width-2), "…") +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go new file mode 100644 index 0000000..af0b432 --- /dev/null +++ b/internal/ui/rain_run_model_test.go @@ -0,0 +1,76 @@ +package ui + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbletea" + + "github.com/git-rain/git-rain/internal/rainexec" +) + +func TestRainRunModel_AppliesProgressEvents(t *testing.T) { + m := rainRunModel{ + rows: []rainRepoRow{{path: "/a", name: "alpha", state: rainexec.StateQueued}}, + } + started := time.Now() + m.applyEvent(rainexec.Event{ + RepoPath: "/a", + State: rainexec.StateRunning, + StartedAt: started, + Attempt: 1, + MaxAttempts: 2, + }) + if m.rows[0].state != rainexec.StateRunning { + t.Fatalf("state = %q", m.rows[0].state) + } + m.applyEvent(rainexec.Event{RepoPath: "/a", State: rainexec.StateRetrying, Attempt: 2, MaxAttempts: 2}) + if m.rows[0].state != rainexec.StateRetrying { + t.Fatalf("state = %q", m.rows[0].state) + } +} + +func TestRainRunModel_SkipKeyInvokesControl(t *testing.T) { + skipped := "" + m := rainRunModel{ + rows: []rainRepoRow{{path: "/a", name: "alpha", state: rainexec.StateRunning}}, + ctrl: RainControls{Skip: func(p string) { skipped = p }}, + } + _, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if skipped != "/a" { + t.Fatalf("skip path = %q", skipped) + } +} + +func TestRainRunModel_AbortKeySetsFlag(t *testing.T) { + aborted := false + m := rainRunModel{ + rows: []rainRepoRow{{path: "/a", name: "alpha"}}, + ctrl: RainControls{Abort: func() { aborted = true }}, + } + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + fm := next.(rainRunModel) + if !fm.aborted || !aborted { + t.Fatalf("expected abort") + } + if cmd == nil { + t.Fatal("expected quit cmd") + } +} + +func TestRainRunModel_OverdueHighlight(t *testing.T) { + m := rainRunModel{ + timeoutSec: 2, + width: 80, + rows: []rainRepoRow{{ + path: "/a", + name: "alpha", + state: rainexec.StateRunning, + startedAt: time.Now().Add(-5 * time.Second), + }}, + } + view := m.formatRow(m.rows[0], true) + if view == "" { + t.Fatal("empty view line") + } +} From 983a45a4626e72f3c4646e317bba38716ffd3a6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 11:10:01 +0000 Subject: [PATCH 02/13] fix(git): classify timeout and cancel errors after process kill Co-authored-by: Ben Schellenberger --- internal/git/network_git.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/git/network_git.go b/internal/git/network_git.go index 67e6cf1..d78c65e 100644 --- a/internal/git/network_git.go +++ b/internal/git/network_git.go @@ -64,6 +64,12 @@ func runNetworkGitOnce(parent context.Context, cmd *exec.Cmd, timeoutSec int) ([ select { case err := <-waitDone: if err != nil { + if errors.Is(runCtx.Err(), context.DeadlineExceeded) && timeoutSec > 0 { + return combined.Bytes(), &FetchTimedOutError{Seconds: timeoutSec} + } + if runCtx.Err() != nil { + return combined.Bytes(), runCtx.Err() + } return combined.Bytes(), err } return combined.Bytes(), nil From 90f4bf369d7d22e40fedb9d080df93392dca631f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:26:26 +0000 Subject: [PATCH 03/13] fix(ui): correct rain skip accounting and timeout highlight Use effective FetchTimeoutSec for overdue highlighting, classify skip vs done without inflating frozen totals, and add outcome helper tests. Co-authored-by: Ben Schellenberger --- cmd/rain_outcome.go | 38 ++++++++++++++++++++++++++++++ cmd/rain_outcome_test.go | 32 +++++++++++++++++++++++++ cmd/rain_tui_run.go | 2 +- cmd/rain_workers.go | 14 ++--------- cmd/root.go | 2 +- internal/ui/rain_run_model_test.go | 21 ++++++++++------- 6 files changed, 86 insertions(+), 23 deletions(-) create mode 100644 cmd/rain_outcome.go create mode 100644 cmd/rain_outcome_test.go diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go new file mode 100644 index 0000000..ea1b3a0 --- /dev/null +++ b/cmd/rain_outcome.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "context" + "errors" + "strings" + + "github.com/git-rain/git-rain/internal/rainexec" +) + +// classifyRepoRainOutcome maps runRainOnRepo totals to a TUI state and adjusts +// counters when a per-repo cancel should count as skipped, not frozen. +func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output string) (rainexec.RepoState, rainTotals) { + cancelled := repoCtx != nil && errors.Is(repoCtx.Err(), context.Canceled) + workDone := delta.updated > 0 || delta.skipped > 0 + + if cancelled { + if workDone { + return rainexec.StateDone, delta + } + d := delta + d.frozen = 0 + d.failed = 0 + return rainexec.StateSkipped, d + } + + switch { + case delta.failed > 0: + return rainexec.StateFailed, delta + case delta.frozen > 0: + if strings.Contains(output, "timed out") { + return rainexec.StateTimedOut, delta + } + return rainexec.StateFrozen, delta + default: + return rainexec.StateDone, delta + } +} diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go new file mode 100644 index 0000000..58f0e5c --- /dev/null +++ b/cmd/rain_outcome_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/git-rain/git-rain/internal/rainexec" +) + +func TestClassifyRepoRainOutcome_SkipDoesNotCountFrozen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state, delta := classifyRepoRainOutcome(ctx, rainTotals{frozen: 1}, "context canceled") + if state != rainexec.StateSkipped { + t.Fatalf("state = %q, want skipped", state) + } + if delta.frozen != 0 { + t.Fatalf("frozen = %d, want 0", delta.frozen) + } +} + +func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, "fetched") + if state != rainexec.StateDone { + t.Fatalf("state = %q, want done", state) + } + if delta.frozen != 1 { + t.Fatalf("delta frozen unchanged when work completed") + } +} diff --git a/cmd/rain_tui_run.go b/cmd/rain_tui_run.go index 405e35a..60d2d07 100644 --- a/cmd/rain_tui_run.go +++ b/cmd/rain_tui_run.go @@ -36,7 +36,7 @@ func runRainOnReposTUI(cfg *config.Config, reg *registry.Registry, repos []git.R } }, } - _ = ui.RunRainProgressTUI(repos, events, ctrl, cfg.Global.FetchTimeoutSec) + _ = ui.RunRainProgressTUI(repos, events, ctrl, opts.FetchTimeoutSec) totals := <-done return printRainSummary(totals, opts.RunCtx) } diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go index 7091092..be0c91a 100644 --- a/cmd/rain_workers.go +++ b/cmd/rain_workers.go @@ -107,20 +107,10 @@ func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOp current := int(atomic.AddInt64(&seq, 1)) var buf strings.Builder delta := runRainOnRepo(reg, repo, repoOpts, fullSync, current, int(total), &buf) - - state := rainexec.StateDone + state, delta := classifyRepoRainOutcome(repoCtx, delta, buf.String()) detail := "" - switch { - case repoCtx != nil && repoCtx.Err() != nil: - state = rainexec.StateSkipped + if state == rainexec.StateSkipped { detail = "skipped" - case delta.failed > 0: - state = rainexec.StateFailed - case delta.frozen > 0: - state = rainexec.StateFrozen - if strings.Contains(buf.String(), "timed out") { - state = rainexec.StateTimedOut - } } emitRainEvent(progress, rainexec.Event{ RepoPath: repo.Path, diff --git a/cmd/root.go b/cmd/root.go index 5e7ab59..316448f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -394,7 +394,7 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai if !fullSync && !rainFetchMainline { if fetchErr := fetchOnly(repoOpts.RunCtx, repo.Path, repoOpts); fetchErr != nil { if errors.Is(fetchErr, context.Canceled) { - _, _ = fmt.Fprintln(out, " ~ skipped\n") + _, _ = fmt.Fprintln(out, " ~ skipped") _, _ = fmt.Fprintln(out) return delta } diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go index af0b432..4f8e61f 100644 --- a/internal/ui/rain_run_model_test.go +++ b/internal/ui/rain_run_model_test.go @@ -62,15 +62,18 @@ func TestRainRunModel_OverdueHighlight(t *testing.T) { m := rainRunModel{ timeoutSec: 2, width: 80, - rows: []rainRepoRow{{ - path: "/a", - name: "alpha", - state: rainexec.StateRunning, - startedAt: time.Now().Add(-5 * time.Second), - }}, } - view := m.formatRow(m.rows[0], true) - if view == "" { - t.Fatal("empty view line") + onTime := rainRepoRow{ + name: "alpha", + state: rainexec.StateRunning, + startedAt: time.Now().Add(-1 * time.Second), + } + overdue := rainRepoRow{ + name: "alpha", + state: rainexec.StateRunning, + startedAt: time.Now().Add(-5 * time.Second), + } + if m.formatRow(onTime, false) == m.formatRow(overdue, false) { + t.Fatal("expected overdue row to differ from on-time row") } } From 84714007d20fd123d4f12fb7390ea14fff733e48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:28:53 +0000 Subject: [PATCH 04/13] fix(test): satisfy errcheck on HTTP retry test body close Co-authored-by: Ben Schellenberger --- internal/git/network_retry_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/git/network_retry_test.go b/internal/git/network_retry_test.go index 603b4f7..39ecf6f 100644 --- a/internal/git/network_retry_test.go +++ b/internal/git/network_retry_test.go @@ -107,7 +107,7 @@ func TestRunNetworkGit_RetriesFlakyHTTPServer(t *testing.T) { if err != nil { return []byte(err.Error()), err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusServiceUnavailable { return []byte("error: RPC failed; HTTP 503"), errors.New("exit 128") } From 437bd5e2509ff9bc761c208507c26e978152e37d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 18:11:33 +0000 Subject: [PATCH 05/13] fix(cmd): drop unused processRainRepositories on TUI stack tip Co-authored-by: Ben Schellenberger --- cmd/root.go | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index f8f7f00..051164e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -380,31 +380,6 @@ func printRainSummary(totals rainTotals, runCtx context.Context) error { return nil } -// processRainRepositories runs fetch or full rain for each repository sequentially -// and accumulates outcome counts. Each repo's output is written as a single block -// directly to stdout. Used for the --rain TUI post-processing path; the default -// CLI run uses runRainDefaultStream for parallel execution. -// Stops starting new repos when RunCtx is cancelled (SIGINT/SIGTERM). -func processRainRepositories(reg *registry.Registry, repos []git.Repository, rainOpts git.RainOptions, fullSync bool, totals *rainTotals, jsonReports *[]jsonRepoResult) { - for i, repo := range repos { - if rainOpts.RunCtx != nil && rainOpts.RunCtx.Err() != nil { - return - } - var buf strings.Builder - delta, report := runRainOnRepo(reg, repo, rainOpts, fullSync, i+1, len(repos), &buf, !rainJSON) - if !rainJSON { - _, _ = io.WriteString(os.Stdout, buf.String()) - } - if jsonReports != nil { - *jsonReports = append(*jsonReports, report) - } - totals.updated += delta.updated - totals.skipped += delta.skipped - totals.frozen += delta.frozen - totals.failed += delta.failed - } -} - // reportRunInterrupted handles SIGINT/SIGTERM after work stops. Human mode prints // Aborted. and returns nil; --json emits schema_version 1 then a non-zero error so // agents do not treat cancel as a successful empty run. From 235ec8d83d398256226f2c9a44d586d2c49bafa9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:39:07 +0000 Subject: [PATCH 06/13] fix(tui): drain rain events, abort on q, truncate before color Prevent workers from blocking after the rain-phase TUI exits by draining the events channel. Treat q like abort so fetches do not continue silently, and truncate plain row text before applying overdue styling so ANSI resets are not clipped. --- cmd/rain_tui_run.go | 5 +++ internal/ui/rain_run_model.go | 12 +++---- internal/ui/rain_run_model_test.go | 52 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/cmd/rain_tui_run.go b/cmd/rain_tui_run.go index d1c7985..e2b8b4f 100644 --- a/cmd/rain_tui_run.go +++ b/cmd/rain_tui_run.go @@ -37,6 +37,11 @@ func runRainOnReposTUI(cfg *config.Config, reg *registry.Registry, repos []git.R }, } _ = ui.RunRainProgressTUI(repos, events, ctrl, opts.FetchTimeoutSec) + // Drain so workers never block on emitRainEvent after the TUI stops consuming. + go func() { + for range events { + } + }() totals := <-done return printRainSummary(totals, opts.RunCtx) } diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go index c9e5409..b7db342 100644 --- a/internal/ui/rain_run_model.go +++ b/internal/ui/rain_run_model.go @@ -114,14 +114,12 @@ func (m rainRunModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ctrl.Skip(row.path) } } - case "a", "ctrl+c": + case "a", "q", "ctrl+c": m.aborted = true if m.ctrl.Abort != nil { m.ctrl.Abort() } return m, tea.Quit - case "q": - return m, tea.Quit } } return m, nil @@ -149,7 +147,7 @@ func (m rainRunModel) View() string { title := lipgloss.NewStyle().Bold(true).Render("🌧 Rain in progress") b.WriteString(title) b.WriteString("\n") - b.WriteString("↑/↓ select · s skip running repo · a abort all · q close when done\n\n") + b.WriteString("↑/↓ select · s skip running repo · a/q abort all\n\n") for i, row := range m.rows { line := m.formatRow(row, i == m.cursor) @@ -199,11 +197,13 @@ func (m rainRunModel) formatRow(row rainRepoRow, selected bool) string { if selected { prefix = "▸ " } - line := prefix + label + " — " + status + // Truncate plain text first; runewidth treats ANSI CSI bytes as width and + // can clip a lipgloss reset if styling is applied before truncation. + line := runewidth.Truncate(prefix+label+" — "+status, max(40, m.width-2), "…") if overdue { line = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) } - return runewidth.Truncate(line, max(40, m.width-2), "…") + return line } func max(a, b int) int { diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go index 4f8e61f..275fd5b 100644 --- a/internal/ui/rain_run_model_test.go +++ b/internal/ui/rain_run_model_test.go @@ -1,10 +1,15 @@ package ui import ( + "fmt" + "strings" "testing" "time" "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" "github.com/git-rain/git-rain/internal/rainexec" ) @@ -58,6 +63,22 @@ func TestRainRunModel_AbortKeySetsFlag(t *testing.T) { } } +func TestRainRunModel_QuitKeyAborts(t *testing.T) { + aborted := false + m := rainRunModel{ + rows: []rainRepoRow{{path: "/a", name: "alpha", state: rainexec.StateRunning}}, + ctrl: RainControls{Abort: func() { aborted = true }}, + } + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + fm := next.(rainRunModel) + if !fm.aborted || !aborted { + t.Fatal("expected q to abort the rain run") + } + if cmd == nil { + t.Fatal("expected quit cmd") + } +} + func TestRainRunModel_OverdueHighlight(t *testing.T) { m := rainRunModel{ timeoutSec: 2, @@ -77,3 +98,34 @@ func TestRainRunModel_OverdueHighlight(t *testing.T) { t.Fatal("expected overdue row to differ from on-time row") } } + +func TestRainRunModel_OverdueTruncateKeepsReset(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + m := rainRunModel{ + timeoutSec: 1, + width: 40, + } + row := rainRepoRow{ + name: strings.Repeat("long-repo-name-", 8), + state: rainexec.StateRunning, + startedAt: time.Now().Add(-10 * time.Second), + } + got := m.formatRow(row, false) + wantWidth := max(40, m.width-2) + if lipgloss.Width(got) != wantWidth { + t.Fatalf("lipgloss.Width = %d, want %d (ANSI reset likely clipped)", lipgloss.Width(got), wantWidth) + } + // Style-then-truncate would clip the reset; truncate-then-style keeps it. + if !strings.HasSuffix(got, "\x1b[0m") { + t.Fatalf("expected intact lipgloss reset suffix, got %q", got) + } + sec := int(time.Since(row.startedAt).Seconds()) + plain := runewidth.Truncate(" "+row.name+fmt.Sprintf(" — running %ds", sec), wantWidth, "…") + want := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(plain) + if got != want { + t.Fatalf("formatRow = %q, want %q", got, want) + } +} From 31b7adfe628cb7a6b36f1392b5f4ad05a65587bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 13:47:04 +0000 Subject: [PATCH 07/13] fix(ui): classify fetch timeouts without human CLI buffer Quiet TUI rain workers suppress per-repo output, so timeout detection now uses report.Error and branch messages instead of parsing the buffer. Co-authored-by: Ben Schellenberger --- cmd/rain_outcome.go | 18 ++++++++++++++++-- cmd/rain_outcome_test.go | 32 ++++++++++++++++++++++++++++++-- cmd/rain_workers.go | 2 +- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go index ea1b3a0..554b9ba 100644 --- a/cmd/rain_outcome.go +++ b/cmd/rain_outcome.go @@ -10,7 +10,9 @@ import ( // classifyRepoRainOutcome maps runRainOnRepo totals to a TUI state and adjusts // counters when a per-repo cancel should count as skipped, not frozen. -func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output string) (rainexec.RepoState, rainTotals) { +// Timeout detection uses the structured report (Error / branch Messages) so it +// works when human CLI output is suppressed (quiet TUI rain phase). +func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, report jsonRepoResult) (rainexec.RepoState, rainTotals) { cancelled := repoCtx != nil && errors.Is(repoCtx.Err(), context.Canceled) workDone := delta.updated > 0 || delta.skipped > 0 @@ -28,7 +30,7 @@ func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output s case delta.failed > 0: return rainexec.StateFailed, delta case delta.frozen > 0: - if strings.Contains(output, "timed out") { + if repoRainTimedOut(report) { return rainexec.StateTimedOut, delta } return rainexec.StateFrozen, delta @@ -36,3 +38,15 @@ func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output s return rainexec.StateDone, delta } } + +func repoRainTimedOut(report jsonRepoResult) bool { + if strings.Contains(report.Error, "timed out") { + return true + } + for _, br := range report.Branches { + if strings.Contains(br.Message, "timed out") { + return true + } + } + return false +} diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go index 58f0e5c..37702df 100644 --- a/cmd/rain_outcome_test.go +++ b/cmd/rain_outcome_test.go @@ -10,7 +10,7 @@ import ( func TestClassifyRepoRainOutcome_SkipDoesNotCountFrozen(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - state, delta := classifyRepoRainOutcome(ctx, rainTotals{frozen: 1}, "context canceled") + state, delta := classifyRepoRainOutcome(ctx, rainTotals{frozen: 1}, jsonRepoResult{Error: "context canceled"}) if state != rainexec.StateSkipped { t.Fatalf("state = %q, want skipped", state) } @@ -22,7 +22,7 @@ func TestClassifyRepoRainOutcome_SkipDoesNotCountFrozen(t *testing.T) { func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, "fetched") + state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, jsonRepoResult{}) if state != rainexec.StateDone { t.Fatalf("state = %q, want done", state) } @@ -30,3 +30,31 @@ func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) { t.Fatalf("delta frozen unchanged when work completed") } } + +func TestClassifyRepoRainOutcome_TimedOutFromReportError(t *testing.T) { + // Quiet TUI path never fills the human buffer; Error still carries the timeout. + state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{ + Error: "timed out after 120s", + }) + if state != rainexec.StateTimedOut { + t.Fatalf("state = %q, want timed_out", state) + } +} + +func TestClassifyRepoRainOutcome_TimedOutFromBranchMessage(t *testing.T) { + state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{ + Branches: []jsonBranchResult{{Message: "timed out after 30s"}}, + }) + if state != rainexec.StateTimedOut { + t.Fatalf("state = %q, want timed_out", state) + } +} + +func TestClassifyRepoRainOutcome_FrozenWithoutTimeout(t *testing.T) { + state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{ + Error: "could not reach remote — check your network and try again", + }) + if state != rainexec.StateFrozen { + t.Fatalf("state = %q, want frozen", state) + } +} diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go index 46f3543..fafbfc6 100644 --- a/cmd/rain_workers.go +++ b/cmd/rain_workers.go @@ -117,7 +117,7 @@ func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOp var buf strings.Builder emitHuman := !quiet && !rainJSON delta, report := runRainOnRepo(reg, repo, repoOpts, fullSync, job.index, total, &buf, emitHuman) - state, delta := classifyRepoRainOutcome(repoCtx, delta, buf.String()) + state, delta := classifyRepoRainOutcome(repoCtx, delta, report) detail := "" if state == rainexec.StateSkipped { detail = "skipped" From 357fda90b99282e66d95112904cea0c063ce6ab4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 18:19:09 +0000 Subject: [PATCH 08/13] fix(cmd): treat branch skips as incomplete on TUI cancel Pre-fetch skipped outcomes (no-upstream, etc.) must not mark a cancelled repo as done or keep frozen counts from the interrupted fetch. Co-authored-by: Ben Schellenberger --- cmd/rain_outcome.go | 4 +++- cmd/rain_outcome_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go index 554b9ba..fcf3263 100644 --- a/cmd/rain_outcome.go +++ b/cmd/rain_outcome.go @@ -14,7 +14,9 @@ import ( // works when human CLI output is suppressed (quiet TUI rain phase). func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, report jsonRepoResult) (rainexec.RepoState, rainTotals) { cancelled := repoCtx != nil && errors.Is(repoCtx.Err(), context.Canceled) - workDone := delta.updated > 0 || delta.skipped > 0 + // Only successful updates count as completed work. Branch skips (no-upstream, + // etc.) are recorded before fetch and must not mask a user cancel as done. + workDone := delta.updated > 0 if cancelled { if workDone { diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go index 37702df..0baccc8 100644 --- a/cmd/rain_outcome_test.go +++ b/cmd/rain_outcome_test.go @@ -58,3 +58,20 @@ func TestClassifyRepoRainOutcome_FrozenWithoutTimeout(t *testing.T) { t.Fatalf("state = %q, want frozen", state) } } + +func TestClassifyRepoRainOutcome_SkipAfterBranchSkipsNotDone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // Pre-fetch skips (e.g. no-upstream) plus frozen from a cancelled fetch must + // still classify as skipped, not done. + state, delta := classifyRepoRainOutcome(ctx, rainTotals{skipped: 1, frozen: 1}, jsonRepoResult{Error: "context canceled"}) + if state != rainexec.StateSkipped { + t.Fatalf("state = %q, want skipped", state) + } + if delta.frozen != 0 { + t.Fatalf("frozen = %d, want 0", delta.frozen) + } + if delta.skipped != 1 { + t.Fatalf("skipped = %d, want 1", delta.skipped) + } +} From f17b0b7b6c491cf87399496a0b850481fecc8092 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:16:13 +0000 Subject: [PATCH 09/13] fix(cmd): update parallel index test for TUI worker signature Co-authored-by: Ben Schellenberger --- cmd/rain_parallel_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go index 8423030..7e43e1c 100644 --- a/cmd/rain_parallel_test.go +++ b/cmd/rain_parallel_test.go @@ -102,7 +102,7 @@ func TestRunRainReposParallel_PreservesSelectionIndex(t *testing.T) { os.Stdout = w defer func() { os.Stdout = oldStdout }() - totals, reports := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{FetchTimeoutSec: 2}, false, 2) + totals, reports := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{FetchTimeoutSec: 2}, false, 2, nil, nil, false) _ = w.Close() var out bytes.Buffer _, _ = io.Copy(&out, r) From 006fb4a6d007c9bbea0b2b4a8fbc0be21e93962f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:28:21 +0000 Subject: [PATCH 10/13] fix(cmd): count cancelled repos as skipped in rain summary Per-repo cancel cleared frozen/failed without ensuring skipped >= 1, so --fetch-mainline cancel outcomes and pre-start worker skips vanished from summary totals while the TUI still showed skipped. --- cmd/rain_outcome.go | 5 +++++ cmd/rain_outcome_test.go | 3 +++ cmd/rain_parallel_test.go | 31 ++++++++++++++++++++++++++++++- cmd/rain_workers.go | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go index fcf3263..4e42228 100644 --- a/cmd/rain_outcome.go +++ b/cmd/rain_outcome.go @@ -25,6 +25,11 @@ func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, report j d := delta d.frozen = 0 d.failed = 0 + // Paths that record cancel as frozen (e.g. --fetch-mainline) leave + // skipped at 0; ensure the repo still appears in the summary. + if d.skipped == 0 { + d.skipped = 1 + } return rainexec.StateSkipped, d } diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go index 0baccc8..0c28d18 100644 --- a/cmd/rain_outcome_test.go +++ b/cmd/rain_outcome_test.go @@ -17,6 +17,9 @@ func TestClassifyRepoRainOutcome_SkipDoesNotCountFrozen(t *testing.T) { if delta.frozen != 0 { t.Fatalf("frozen = %d, want 0", delta.frozen) } + if delta.skipped != 1 { + t.Fatalf("skipped = %d, want 1", delta.skipped) + } } func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) { diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go index 7e43e1c..77413ff 100644 --- a/cmd/rain_parallel_test.go +++ b/cmd/rain_parallel_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "io" "os" @@ -9,9 +10,10 @@ import ( "testing" "time" + testutil "github.com/git-fire/git-testkit" "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/rainexec" "github.com/git-rain/git-rain/internal/registry" - testutil "github.com/git-fire/git-testkit" ) func TestRunRainOnRepos_ParallelStallDoesNotBlockHealthy(t *testing.T) { @@ -135,3 +137,30 @@ func TestRunRainReposParallel_PreservesSelectionIndex(t *testing.T) { } } } + +func TestRunRainReposParallel_PreStartCancelCountsSkipped(t *testing.T) { + repos := []git.Repository{{Name: "pending", Path: "/tmp/git-rain-never-started"}} + coord := newRainCoordinator(context.Background(), []string{repos[0].Path}) + coord.SkipRepo(repos[0].Path) + + progress := make(chan rainexec.Event, 4) + totals, _ := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{}, false, 1, progress, coord, true) + close(progress) + + if totals.skipped != 1 { + t.Fatalf("totals.skipped = %d, want 1 (pre-start cancel must count in summary)", totals.skipped) + } + if totals.updated != 0 || totals.frozen != 0 || totals.failed != 0 { + t.Fatalf("unexpected non-skip totals: %+v", totals) + } + + var sawSkipped bool + for e := range progress { + if e.State == rainexec.StateSkipped { + sawSkipped = true + } + } + if !sawSkipped { + t.Fatal("expected skipped progress event for pre-start cancel") + } +} diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go index fafbfc6..3c27394 100644 --- a/cmd/rain_workers.go +++ b/cmd/rain_workers.go @@ -81,6 +81,7 @@ func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOp State: rainexec.StateSkipped, Detail: "skipped", }) + atomic.AddInt64(&skipped, 1) continue } From 648d3df226c0112a5a68d2d5b42acce697dfe0df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:57:53 +0000 Subject: [PATCH 11/13] fix(cmd): count queued repos as skipped when rain run aborts Co-authored-by: Ben Schellenberger --- cmd/rain_parallel_test.go | 15 +++++++++++++++ cmd/rain_workers.go | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go index 77413ff..fddebf0 100644 --- a/cmd/rain_parallel_test.go +++ b/cmd/rain_parallel_test.go @@ -164,3 +164,18 @@ func TestRunRainReposParallel_PreStartCancelCountsSkipped(t *testing.T) { t.Fatal("expected skipped progress event for pre-start cancel") } } + +func TestRunRainReposParallel_AbortCountsQueuedRepos(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + repos := []git.Repository{ + {Name: "a", Path: "/tmp/git-rain-abort-a"}, + {Name: "b", Path: "/tmp/git-rain-abort-b"}, + {Name: "c", Path: "/tmp/git-rain-abort-c"}, + } + totals, _ := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{RunCtx: ctx}, false, 2, nil, nil, true) + if totals.skipped != 3 { + t.Fatalf("totals.skipped = %d, want 3 (queued repos after abort)", totals.skipped) + } +} diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go index 3c27394..8636da2 100644 --- a/cmd/rain_workers.go +++ b/cmd/rain_workers.go @@ -68,6 +68,13 @@ func runRainReposParallel(reg *registry.Registry, repos []git.Repository, rainOp for job := range repoChan { repo := job.repo if runCtx != nil && runCtx.Err() != nil { + emitRainEvent(progress, rainexec.Event{ + RepoPath: repo.Path, + RepoName: repo.Name, + State: rainexec.StateSkipped, + Detail: "skipped", + }) + atomic.AddInt64(&skipped, 1) continue } repoCtx := runCtx From 1a8a15dda7208bade617858e6f00e06c4010cf43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 12:11:31 +0000 Subject: [PATCH 12/13] fix(ui): scroll rain status list to keep cursor visible Rain progress TUI now tracks a list viewport offset (same clamp logic as the repo picker) so running/overdue repos stay on screen when the repo count exceeds terminal height. Co-authored-by: Ben Schellenberger --- internal/ui/rain_run_model.go | 97 +++++++++++++++++++++++++++--- internal/ui/rain_run_model_test.go | 44 ++++++++++++++ internal/ui/repo_selector.go | 26 +------- internal/ui/view_layout.go | 29 +++++++++ 4 files changed, 161 insertions(+), 35 deletions(-) diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go index b7db342..3872bdf 100644 --- a/internal/ui/rain_run_model.go +++ b/internal/ui/rain_run_model.go @@ -32,14 +32,15 @@ type rainRepoRow struct { } type rainRunModel struct { - rows []rainRepoRow - cursor int - timeoutSec int - events <-chan rainexec.Event - ctrl RainControls - aborted bool - width int - height int + rows []rainRepoRow + cursor int + scrollOffset int + timeoutSec int + events <-chan rainexec.Event + ctrl RainControls + aborted bool + width int + height int } type rainEventMsg rainexec.Event @@ -91,6 +92,7 @@ func (m rainRunModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.scrollOffset = m.clampRainScroll() return m, nil case rainEventMsg: m.applyEvent(rainexec.Event(msg)) @@ -102,10 +104,12 @@ func (m rainRunModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "up", "k": if m.cursor > 0 { m.cursor-- + m.scrollOffset = m.clampRainScroll() } case "down", "j": if m.cursor < len(m.rows)-1 { m.cursor++ + m.scrollOffset = m.clampRainScroll() } case "s": if m.ctrl.Skip != nil && m.cursor < len(m.rows) { @@ -142,6 +146,67 @@ func (m *rainRunModel) applyEvent(e rainexec.Event) { } } +func (m rainRunModel) rainListCapacity() int { + const headerLines = 3 // title, help, blank line before list + if m.height <= headerLines { + return len(m.rows) + } + capacity := m.height - headerLines + if capacity < 1 { + return 1 + } + return capacity +} + +type rainListViewport struct { + offset int + end int + hasAbove bool + hasBelow bool + belowCount int +} + +func rainListViewportFor(scrollOffset, cursor, capacity, total int) rainListViewport { + offset := scrollOffset + for { + indicators := 0 + if offset > 0 { + indicators++ + } + provisional := capacity - indicators + if provisional < 1 { + provisional = 1 + } + hasBelow := offset+provisional < total + if hasBelow { + indicators++ + } + itemVisible := capacity - indicators + if itemVisible < 1 { + itemVisible = 1 + } + next := clampListScroll(offset, cursor, itemVisible, total) + if next == offset { + end := offset + itemVisible + if end > total { + end = total + } + return rainListViewport{ + offset: offset, + end: end, + hasAbove: offset > 0, + hasBelow: end < total, + belowCount: total - end, + } + } + offset = next + } +} + +func (m rainRunModel) clampRainScroll() int { + return rainListViewportFor(m.scrollOffset, m.cursor, m.rainListCapacity(), len(m.rows)).offset +} + func (m rainRunModel) View() string { var b strings.Builder title := lipgloss.NewStyle().Bold(true).Render("🌧 Rain in progress") @@ -149,11 +214,23 @@ func (m rainRunModel) View() string { b.WriteString("\n") b.WriteString("↑/↓ select · s skip running repo · a/q abort all\n\n") - for i, row := range m.rows { - line := m.formatRow(row, i == m.cursor) + total := len(m.rows) + if total == 0 { + return b.String() + } + + vp := rainListViewportFor(m.scrollOffset, m.cursor, m.rainListCapacity(), total) + if vp.hasAbove { + b.WriteString(fmt.Sprintf(" ↑ %d more\n", vp.offset)) + } + for i := vp.offset; i < vp.end; i++ { + line := m.formatRow(m.rows[i], i == m.cursor) b.WriteString(line) b.WriteString("\n") } + if vp.hasBelow { + b.WriteString(fmt.Sprintf(" ↓ %d more\n", vp.belowCount)) + } return b.String() } diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go index 275fd5b..2afa81a 100644 --- a/internal/ui/rain_run_model_test.go +++ b/internal/ui/rain_run_model_test.go @@ -99,6 +99,50 @@ func TestRainRunModel_OverdueHighlight(t *testing.T) { } } +func TestRainRunModel_ViewScrollsToFollowCursor(t *testing.T) { + rows := make([]rainRepoRow, 25) + for i := range rows { + rows[i] = rainRepoRow{ + path: fmt.Sprintf("/repo/%d", i), + name: fmt.Sprintf("repo-%02d", i), + state: rainexec.StateQueued, + } + } + m := rainRunModel{ + rows: rows, + cursor: 20, + height: 10, + width: 80, + } + m.scrollOffset = m.clampRainScroll() + view := m.View() + if !strings.Contains(view, "repo-20") { + t.Fatalf("expected cursor repo in view, got:\n%s", view) + } + if strings.Contains(view, "repo-00\n") || strings.Contains(view, " repo-00") { + t.Fatalf("first repo should be off-screen when cursor is near the end") + } + + m = rainRunModel{rows: rows, cursor: 0, height: 10, width: 80} + m.scrollOffset = m.clampRainScroll() + view = m.View() + if !strings.Contains(view, "repo-00") { + t.Fatalf("expected first repo visible at top cursor") + } + + for i := 0; i < 18; i++ { + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = next.(rainRunModel) + } + view = m.View() + if m.cursor != 18 { + t.Fatalf("cursor = %d", m.cursor) + } + if !strings.Contains(view, "repo-18") { + t.Fatalf("expected repo-18 visible after scrolling down, got:\n%s", view) + } +} + func TestRainRunModel_OverdueTruncateKeepsReset(t *testing.T) { prev := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.ANSI256) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 9197be6..7483818 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -745,31 +745,7 @@ func (m RepoSelectorModel) withClampedPathScroll() RepoSelectorModel { } func (m RepoSelectorModel) clampScroll(offset, cursor, visible, total int) int { - if visible <= 0 || total == 0 { - return 0 - } - if offset > total-visible { - offset = total - visible - } - if offset < 0 { - offset = 0 - } - itemVisible := visible - var next int - for { - if cursor < offset { - next = cursor - } else if cursor >= offset+itemVisible { - next = cursor - itemVisible + 1 - } else { - next = offset - } - if next == offset { - break - } - offset = next - } - return offset + return clampListScroll(offset, cursor, visible, total) } func (m RepoSelectorModel) contentWidth() int { diff --git a/internal/ui/view_layout.go b/internal/ui/view_layout.go index 2a9a0f7..3f30454 100644 --- a/internal/ui/view_layout.go +++ b/internal/ui/view_layout.go @@ -397,3 +397,32 @@ func (m RepoSelectorModel) ignoredMeasuredListCapacity() int { } return best } + +// clampListScroll keeps cursor visible within a list viewport of visible rows. +func clampListScroll(offset, cursor, visible, total int) int { + if visible <= 0 || total == 0 { + return 0 + } + if offset > total-visible { + offset = total - visible + } + if offset < 0 { + offset = 0 + } + itemVisible := visible + var next int + for { + if cursor < offset { + next = cursor + } else if cursor >= offset+itemVisible { + next = cursor - itemVisible + 1 + } else { + next = offset + } + if next == offset { + break + } + offset = next + } + return offset +} From d5239f5b25f6e172c4b978f1b252bb6e936e7709 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 12:21:55 +0000 Subject: [PATCH 13/13] fix(ui): satisfy staticcheck QF1012 in rain list indicators Co-authored-by: Ben Schellenberger --- internal/ui/rain_run_model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go index 3872bdf..26b93ca 100644 --- a/internal/ui/rain_run_model.go +++ b/internal/ui/rain_run_model.go @@ -221,7 +221,7 @@ func (m rainRunModel) View() string { vp := rainListViewportFor(m.scrollOffset, m.cursor, m.rainListCapacity(), total) if vp.hasAbove { - b.WriteString(fmt.Sprintf(" ↑ %d more\n", vp.offset)) + fmt.Fprintf(&b, " ↑ %d more\n", vp.offset) } for i := vp.offset; i < vp.end; i++ { line := m.formatRow(m.rows[i], i == m.cursor) @@ -229,7 +229,7 @@ func (m rainRunModel) View() string { b.WriteString("\n") } if vp.hasBelow { - b.WriteString(fmt.Sprintf(" ↓ %d more\n", vp.belowCount)) + fmt.Fprintf(&b, " ↓ %d more\n", vp.belowCount) } return b.String() }