Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3c2baef
feat(ui): live per-repo rain status in --rain TUI
cursoragent Oct 11, 2026
983a45a
fix(git): classify timeout and cancel errors after process kill
cursoragent Oct 11, 2026
f9b9aa2
Merge remote-tracking branch 'origin/cursor/network-failfast-env-971d…
cursoragent Oct 11, 2026
90f4bf3
fix(ui): correct rain skip accounting and timeout highlight
cursoragent Oct 11, 2026
8471400
fix(test): satisfy errcheck on HTTP retry test body close
cursoragent Oct 11, 2026
e81f872
Merge remote-tracking branch 'origin/cursor/fetch-retry-transient-971…
cursoragent Oct 11, 2026
d5facd2
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 12, 2026
9c4765a
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 12, 2026
9de0ddf
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 12, 2026
437bd5e
fix(cmd): drop unused processRainRepositories on TUI stack tip
cursoragent Oct 12, 2026
9cfd227
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 13, 2026
1f49541
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 13, 2026
235ec8d
fix(tui): drain rain events, abort on q, truncate before color
cursoragent Oct 11, 2026
31b7adf
fix(ui): classify fetch timeouts without human CLI buffer
cursoragent Oct 12, 2026
357fda9
fix(cmd): treat branch skips as incomplete on TUI cancel
cursoragent Oct 12, 2026
f17b0b7
fix(cmd): update parallel index test for TUI worker signature
cursoragent Oct 13, 2026
3a925f0
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 13, 2026
006fb4a
fix(cmd): count cancelled repos as skipped in rain summary
cursoragent Oct 13, 2026
a26a459
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 13, 2026
648d3df
fix(cmd): count queued repos as skipped when rain run aborts
cursoragent Oct 13, 2026
42a4aac
Merge cursor/fetch-retry-transient-971d into cursor/tui-rain-status-971d
cursoragent Oct 13, 2026
1a8a15d
fix(ui): scroll rain status list to keep cursor visible
cursoragent Oct 14, 2026
d5239f5
fix(ui): satisfy staticcheck QF1012 in rain list indicators
cursoragent Oct 14, 2026
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,5 @@ Live runs pipeline **filesystem scan → registry upsert → parallel per-repo w
- **Manual end-to-end check:** create a bare remote + a clone under a scan dir, advance the remote, then `./git-rain --sync --path <dir>` 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).
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,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)
Expand Down Expand Up @@ -432,7 +435,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.

Expand Down
56 changes: 56 additions & 0 deletions cmd/rain_coordinator.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
59 changes: 59 additions & 0 deletions cmd/rain_outcome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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.
// 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)
// 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 {
return rainexec.StateDone, delta
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

switch {
case delta.failed > 0:
return rainexec.StateFailed, delta
case delta.frozen > 0:
if repoRainTimedOut(report) {
return rainexec.StateTimedOut, delta
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return rainexec.StateFrozen, delta
default:
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
}
80 changes: 80 additions & 0 deletions cmd/rain_outcome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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}, 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)
}
}

func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, jsonRepoResult{})
if state != rainexec.StateDone {
t.Fatalf("state = %q, want done", state)
}
if delta.frozen != 1 {
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)
}
}

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)
}
}
50 changes: 47 additions & 3 deletions cmd/rain_parallel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"strings"
"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) {
Expand Down Expand Up @@ -47,7 +49,7 @@ func TestRunRainOnRepos_ParallelStallDoesNotBlockHealthy(t *testing.T) {
defer func() { os.Stdout = oldStdout }()

start := time.Now()
err := runRainOnRepos(&registry.Registry{}, repos, rainOpts, false, 2)
err := runRainOnRepos(&registry.Registry{}, repos, rainOpts, false, 2) // CLI path (parallel stdout)
elapsed := time.Since(start)

_ = w.Close()
Expand Down Expand Up @@ -102,7 +104,7 @@ func TestRunRainReposParallel_PreservesSelectionIndex(t *testing.T) {
os.Stdout = w
defer func() { os.Stdout = oldStdout }()

totals, reports := runRainReposParallel(&registry.Registry{}, repos, git.RainOptions{FetchTimeoutSec: 2}, false, 2)
totals, reports := runRainReposParallel(&registry.Registry{}, repos, git.RainOptions{FetchTimeoutSec: 2}, false, 2, nil, nil, false)
_ = w.Close()
var out bytes.Buffer
_, _ = io.Copy(&out, r)
Expand Down Expand Up @@ -135,3 +137,45 @@ 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(&registry.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")
}
}

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(&registry.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)
}
}
47 changes: 47 additions & 0 deletions cmd/rain_tui_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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, opts.FetchTimeoutSec)
// Drain so workers never block on emitRainEvent after the TUI stops consuming.
go func() {
for range events {
}
}()
totals := <-done
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return printRainSummary(totals, opts.RunCtx)
}
Loading
Loading