From c60bf26a7821110717b48edabe995d32da6cebae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 10:56:50 +0000 Subject: [PATCH 1/6] feat(cli): cancel in-flight fetch on SIGINT/SIGTERM Wire signal.NotifyContext into default streaming and post-TUI runs so Ctrl-C kills network git via RunNetworkGit, prints a partial summary, and exits non-zero. Co-authored-by: Ben Schellenberger --- cmd/root.go | 78 ++++++++++++++++++++------------ internal/git/network_git_test.go | 31 +++++++++++++ 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 64e76cc..e82e256 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,11 +9,13 @@ import ( "io" "os" "os/exec" + "os/signal" "path/filepath" "strconv" "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/mattn/go-runewidth" @@ -212,6 +214,10 @@ func runRain(_ *cobra.Command, _ []string) error { return runDryRun(opts, rainOpts, fullSync, cfg) } + runCtx, stopRun := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopRun() + rainOpts.RunCtx = runCtx + if rainRain { return runRainTUIStream(cfg, reg, regPath, opts, rainOpts, fullSync) } @@ -319,12 +325,36 @@ type rainTotals struct { failed int } +func printRainSummary(totals rainTotals, runCtx context.Context) error { + fmt.Println(strings.Repeat("─", 48)) + if runCtx != nil && runCtx.Err() != nil { + fmt.Printf("🌧 rain interrupted β€” %d updated, %d skipped, %d frozen, %d failed\n", + totals.updated, totals.skipped, totals.frozen, totals.failed) + return fmt.Errorf("interrupted") + } + if totals.failed > 0 { + fmt.Printf("🌧 rain stopped β€” %d updated, %d skipped, %d frozen, %d failed\n", + totals.updated, totals.skipped, totals.frozen, totals.failed) + return fmt.Errorf("%d branch(es) failed β€” check output above", totals.failed) + } + if totals.frozen > 0 { + fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped, %d frozen (try again when reachable)\n", + totals.updated, totals.skipped, totals.frozen) + return nil + } + fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped\n", totals.updated, totals.skipped) + 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. func processRainRepositories(reg *registry.Registry, repos []git.Repository, rainOpts git.RainOptions, fullSync bool, totals *rainTotals) { for i, repo := range repos { + if rainOpts.RunCtx != nil && rainOpts.RunCtx.Err() != nil { + break + } var buf strings.Builder delta := runRainOnRepo(reg, repo, rainOpts, fullSync, i+1, len(repos), &buf) _, _ = io.WriteString(os.Stdout, buf.String()) @@ -483,7 +513,11 @@ func runDryRun(scanOpts git.ScanOptions, rainOpts git.RainOptions, fullSync bool // 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 { - ctx, cancelScan := context.WithCancel(context.Background()) + scanParent := rainOpts.RunCtx + if scanParent == nil { + scanParent = context.Background() + } + ctx, cancelScan := context.WithCancel(scanParent) defer cancelScan() opts.Ctx = ctx @@ -667,7 +701,11 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st } fmt.Println() - ctx, cancelScan := context.WithCancel(context.Background()) + scanParent := rainOpts.RunCtx + if scanParent == nil { + scanParent = context.Background() + } + ctx, cancelScan := context.WithCancel(scanParent) defer cancelScan() opts.Ctx = ctx @@ -708,6 +746,8 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st const scanPrefix = " πŸ” Scanning… " for { select { + case <-ctx.Done(): + return case <-scanDone: return case <-tick.C: @@ -767,6 +807,9 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st go func() { defer workersWG.Done() for repo := range repoChan { + if ctx.Err() != nil { + break + } current := int(atomic.AddInt64(&seq, 1)) total := int(atomic.LoadInt64(&totalFound)) // While the scan is still running, total may equal current @@ -805,6 +848,9 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st defer printMu.Unlock() if atomic.LoadInt64(&seq) == 0 { + if rainOpts.RunCtx != nil && rainOpts.RunCtx.Err() != nil { + return fmt.Errorf("interrupted") + } fmt.Println("No git repositories found.") return nil } @@ -816,19 +862,7 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st failed: int(atomic.LoadInt64(&failed)), } - fmt.Println(strings.Repeat("─", 48)) - if totals.failed > 0 { - fmt.Printf("🌧 rain stopped β€” %d updated, %d skipped, %d frozen, %d failed\n", - totals.updated, totals.skipped, totals.frozen, totals.failed) - return fmt.Errorf("%d branch(es) failed β€” check output above", totals.failed) - } - if totals.frozen > 0 { - fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped, %d frozen (try again when reachable)\n", - totals.updated, totals.skipped, totals.frozen) - return nil - } - fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped\n", totals.updated, totals.skipped) - return nil + return printRainSummary(totals, rainOpts.RunCtx) } // runRainOnRepos runs fetch or full rain on a pre-selected list of repos. @@ -850,19 +884,7 @@ func runRainOnRepos(reg *registry.Registry, repos []git.Repository, opts git.Rai totals := rainTotals{} processRainRepositories(reg, repos, opts, fullSync, &totals) - fmt.Println(strings.Repeat("─", 48)) - if totals.failed > 0 { - fmt.Printf("🌧 rain stopped β€” %d updated, %d skipped, %d frozen, %d failed\n", - totals.updated, totals.skipped, totals.frozen, totals.failed) - return fmt.Errorf("%d branch(es) failed β€” check output above", totals.failed) - } - if totals.frozen > 0 { - fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped, %d frozen (try again when reachable)\n", - totals.updated, totals.skipped, totals.frozen) - return nil - } - fmt.Printf("🌧 rain delivered β€” %d updated, %d skipped\n", totals.updated, totals.skipped) - return nil + return printRainSummary(totals, opts.RunCtx) } func applyRepoFetchPrune(repoPath string, base git.RainOptions, absPath string, reg *registry.Registry) (git.RainOptions, error) { diff --git a/internal/git/network_git_test.go b/internal/git/network_git_test.go index 9f92636..8ce0f13 100644 --- a/internal/git/network_git_test.go +++ b/internal/git/network_git_test.go @@ -75,6 +75,37 @@ func TestRunNetworkGit_TimesOutStallRemote(t *testing.T) { } } +func TestRunNetworkGit_CancelStopsInFlightFetch(t *testing.T) { + baseURL, cleanup := startStallTCPListener(t) + defer cleanup() + + repo := testutil.CreateTestRepo(t, testutil.RepoOptions{ + Name: "cancel-stall", + Remotes: map[string]string{ + "origin": baseURL + "/stall.git", + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(300 * time.Millisecond) + cancel() + }() + + start := time.Now() + cmd := exec.Command("git", "fetch", "--all") + cmd.Dir = repo + _, err := RunNetworkGit(ctx, cmd, 120) + elapsed := time.Since(start) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunNetworkGit() err = %v, want context.Canceled", err) + } + if elapsed > 5*time.Second { + t.Fatalf("cancel took %v; expected subprocess to stop quickly", elapsed) + } +} + func TestRunNetworkGit_HealthyRemoteCompletes(t *testing.T) { scenario := testutil.NewScenario(t) remote := scenario.CreateBareRepo("upstream") From aff1b1606d95ed5ca28560cd56e4920d43a7add9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 11:10:01 +0000 Subject: [PATCH 2/6] 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 00e0d92..bfcb597 100644 --- a/internal/git/network_git.go +++ b/internal/git/network_git.go @@ -67,6 +67,12 @@ func RunNetworkGit(parent context.Context, cmd *exec.Cmd, timeoutSec int) ([]byt 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 5e8a3eaa3f88353279f630e95af4b9ef97587ea2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:24:46 +0000 Subject: [PATCH 3/6] fix(cli): drain repo channel on interrupt without counting cancel as frozen Co-authored-by: Ben Schellenberger --- cmd/root.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index e82e256..9d43608 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -391,6 +391,11 @@ 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) + return delta + } _, _ = fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))) delta.frozen++ @@ -808,7 +813,8 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st defer workersWG.Done() for repo := range repoChan { if ctx.Err() != nil { - break + // Drain repoChan so the upsert goroutine can finish and close it. + continue } current := int(atomic.AddInt64(&seq, 1)) total := int(atomic.LoadInt64(&totalFound)) From 048bf2d83cc5e037a426701aa5c512c408d19e15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:25:03 +0000 Subject: [PATCH 4/6] fix(git): do not freeze branches when fetch context is canceled Co-authored-by: Ben Schellenberger --- internal/git/rain.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/git/rain.go b/internal/git/rain.go index 1836ab6..c9bdbf1 100644 --- a/internal/git/rain.go +++ b/internal/git/rain.go @@ -222,6 +222,9 @@ func RainRepository(repoPath string, opts RainOptions) (RainResult, error) { } output, fetchErr := RunNetworkGit(runCtx, cmd, opts.FetchTimeoutSec) if fetchErr != nil { + if errors.Is(fetchErr, context.Canceled) { + return result, nil + } // Freeze gracefully β€” could not reach remote (auth, network, etc.). // This is not a hard failure; the repo is untouched. Try again later. reason := fetchFailureReasonForErr(output, fetchErr) From dd9715bf759f75fa8e58996997960a49c73dd450 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 12:29:02 +0000 Subject: [PATCH 5/6] fix(cli): remove redundant newline in skipped fetch message Co-authored-by: Ben Schellenberger --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 9d43608..b16a797 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -392,7 +392,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 } From c9dbb1f7f8048f34a55e57a8c6b965c6dcfd493a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 18:04:21 +0000 Subject: [PATCH 6/6] fix: count canceled fetch --all as skipped in totals and JSON Canceled fetches already printed "~ skipped" but left delta.skipped and summary_outcome unset, so interrupted repos disappeared from totals and JSON reports. Set both to match the human output. --- cmd/root.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index e809aa1..427188c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -451,6 +451,8 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai _, _ = fmt.Fprintln(out, " ~ skipped") _, _ = fmt.Fprintln(out) } + delta.skipped++ + report.SummaryOutcome = "skipped" return delta, report } msg := safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))