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 +}