diff --git a/AGENTS.md b/AGENTS.md
index 87a51ea..aafe837 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
` 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 98d3889..ddede18 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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.
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_outcome.go b/cmd/rain_outcome.go
new file mode 100644
index 0000000..4e42228
--- /dev/null
+++ b/cmd/rain_outcome.go
@@ -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
+ }
+ 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
+ }
+
+ switch {
+ case delta.failed > 0:
+ return rainexec.StateFailed, delta
+ case delta.frozen > 0:
+ if repoRainTimedOut(report) {
+ return rainexec.StateTimedOut, delta
+ }
+ 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
+}
diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go
new file mode 100644
index 0000000..0c28d18
--- /dev/null
+++ b/cmd/rain_outcome_test.go
@@ -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)
+ }
+}
diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go
index 5e514a0..c0202bb 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) {
@@ -47,7 +49,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()
@@ -102,7 +104,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)
@@ -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(®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")
+ }
+}
+
+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_tui_run.go b/cmd/rain_tui_run.go
new file mode 100644
index 0000000..e2b8b4f
--- /dev/null
+++ b/cmd/rain_tui_run.go
@@ -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
+ return printRainSummary(totals, opts.RunCtx)
+}
diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go
index 45befb0..8636da2 100644
--- a/cmd/rain_workers.go
+++ b/cmd/rain_workers.go
@@ -6,24 +6,42 @@ 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
+}
+
type parallelRepoJob struct {
repo git.Repository
index int // 1-based position in the selected repos slice
}
// 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, []jsonRepoResult) {
+// 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, []jsonRepoResult) {
if len(repos) == 0 {
return rainTotals{}, nil
}
total := len(repos)
+ for _, repo := range repos {
+ emitRainEvent(progress, rainexec.Event{
+ RepoPath: repo.Path,
+ RepoName: repo.Name,
+ State: rainexec.StateQueued,
+ })
+ }
+
workers := fetchWorkerCount(fetchWorkers)
repoChan := make(chan parallelRepoJob, len(repos))
for i, repo := range repos {
@@ -50,16 +68,81 @@ 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
+ 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",
+ })
+ atomic.AddInt64(&skipped, 1)
continue
}
+
+ started := time.Now()
+ maxAttempts := rainOpts.FetchRetryMaxAttempts
+ if maxAttempts < 0 {
+ maxAttempts = git.DefaultFetchRetryMaxAttempts
+ }
+ if maxAttempts == 0 {
+ maxAttempts = 1
+ }
+ 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,
+ })
+ }
+
var buf strings.Builder
- delta, report := runRainOnRepo(reg, repo, rainOpts, fullSync, job.index, total, &buf, !rainJSON)
+ emitHuman := !quiet && !rainJSON
+ delta, report := runRainOnRepo(reg, repo, repoOpts, fullSync, job.index, total, &buf, emitHuman)
+ state, delta := classifyRepoRainOutcome(repoCtx, delta, report)
+ detail := ""
+ if state == rainexec.StateSkipped {
+ detail = "skipped"
+ }
+ emitRainEvent(progress, rainexec.Event{
+ RepoPath: repo.Path,
+ RepoName: repo.Name,
+ State: state,
+ StartedAt: started,
+ Detail: detail,
+ })
- printMu.Lock()
- if !rainJSON {
+ if emitHuman {
+ printMu.Lock()
_, _ = io.WriteString(os.Stdout, buf.String())
+ printMu.Unlock()
}
- printMu.Unlock()
if rainJSON {
jsonReportsMu.Lock()
diff --git a/cmd/root.go b/cmd/root.go
index cc5c518..c366d11 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -253,7 +253,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)
@@ -649,7 +649,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 {
// Derive scan cancellation from RunCtx so SIGINT/SIGTERM stops the walk
// (and in-flight analyze git) instead of only the network process group.
parent := rainOpts.RunCtx
@@ -751,7 +751,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
@@ -1067,7 +1067,7 @@ func runRainOnRepos(reg *registry.Registry, repos []git.Repository, opts git.Rai
fmt.Println()
}
- totals, jsonReports := runRainReposParallel(reg, repos, opts, fullSync, fetchWorkers)
+ totals, jsonReports := runRainReposParallel(reg, repos, opts, fullSync, fetchWorkers, nil, nil, false)
if opts.RunCtx != nil && opts.RunCtx.Err() != nil {
return reportRunInterrupted(opts, fullSync, totals, jsonReports)
diff --git a/internal/git/network_retry.go b/internal/git/network_retry.go
index 3c6551d..0044ff9 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.
@@ -31,6 +32,7 @@ func NetworkGitParamsFromRain(opts RainOptions) NetworkGitParams {
MaxAttempts: opts.FetchRetryMaxAttempts,
InitialBackoffSec: opts.FetchRetryInitialBackoffSec,
MaxBackoffSec: opts.FetchRetryMaxBackoffSec,
+ OnRetry: opts.OnFetchAttempt,
}
}
@@ -81,6 +83,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 51f22a4..16b11ea 100644
--- a/internal/git/network_retry_test.go
+++ b/internal/git/network_retry_test.go
@@ -329,3 +329,4 @@ func TestRunNetworkGit_RetriesTCPResetServer(t *testing.T) {
t.Fatalf("expected success on second attempt: %v", err)
}
}
+
diff --git a/internal/git/rain.go b/internal/git/rain.go
index fa6a5e9..cb949b3 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..26b93ca
--- /dev/null
+++ b/internal/ui/rain_run_model.go
@@ -0,0 +1,291 @@
+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
+ scrollOffset 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
+ m.scrollOffset = m.clampRainScroll()
+ 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--
+ 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) {
+ row := m.rows[m.cursor]
+ if row.state == rainexec.StateRunning || row.state == rainexec.StateRetrying {
+ m.ctrl.Skip(row.path)
+ }
+ }
+ case "a", "q", "ctrl+c":
+ m.aborted = true
+ if m.ctrl.Abort != nil {
+ m.ctrl.Abort()
+ }
+ 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) 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")
+ b.WriteString(title)
+ b.WriteString("\n")
+ b.WriteString("↑/↓ select · s skip running repo · a/q abort all\n\n")
+
+ total := len(m.rows)
+ if total == 0 {
+ return b.String()
+ }
+
+ vp := rainListViewportFor(m.scrollOffset, m.cursor, m.rainListCapacity(), total)
+ if vp.hasAbove {
+ 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)
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ if vp.hasBelow {
+ fmt.Fprintf(&b, " ↓ %d more\n", vp.belowCount)
+ }
+ 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 = "▸ "
+ }
+ // 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 line
+}
+
+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..2afa81a
--- /dev/null
+++ b/internal/ui/rain_run_model_test.go
@@ -0,0 +1,175 @@
+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"
+)
+
+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_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,
+ width: 80,
+ }
+ 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")
+ }
+}
+
+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)
+ 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)
+ }
+}
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
+}