diff --git a/cmd/root.go b/cmd/root.go index 7b9ceb9..427188c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -227,6 +227,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) } @@ -334,6 +338,27 @@ 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 @@ -421,6 +446,15 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai fetchCtx = rainOpts.RunCtx } if fetchErr := fetchOnly(fetchCtx, repo.Path, repoOpts); fetchErr != nil { + if errors.Is(fetchErr, context.Canceled) { + if emitHuman { + _, _ = fmt.Fprintln(out, " ~ skipped") + _, _ = fmt.Fprintln(out) + } + delta.skipped++ + report.SummaryOutcome = "skipped" + return delta, report + } msg := safety.SanitizeText(fetchFailureMessage(fetchErr.Error())) if emitHuman { _, _ = fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", msg) @@ -852,6 +886,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: @@ -916,7 +952,7 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st go func() { defer workersWG.Done() for repo := range repoChan { - // Drain remaining work without fetching after interrupt/cancel. + // Drain repoChan so the upsert goroutine can finish and close it. if ctx.Err() != nil { continue } @@ -977,6 +1013,12 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st } if atomic.LoadInt64(&seq) == 0 { + if rainOpts.RunCtx != nil && rainOpts.RunCtx.Err() != nil { + if rainJSON { + return emitJSONRun(buildJSONRunMeta(rainOpts, fullSync), rainTotals{}, nil) + } + return fmt.Errorf("interrupted") + } if rainJSON { return emitJSONRun(buildJSONRunMeta(rainOpts, fullSync), rainTotals{}, nil) } @@ -988,25 +1030,16 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st if err := emitJSONRun(buildJSONRunMeta(rainOpts, fullSync), totals, jsonReports); err != nil { return err } + if rainOpts.RunCtx != nil && rainOpts.RunCtx.Err() != nil { + return fmt.Errorf("interrupted") + } if totals.failed > 0 { return fmt.Errorf("%d branch(es) failed β€” see --json output", totals.failed) } return nil } - 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. @@ -1047,19 +1080,7 @@ func runRainOnRepos(reg *registry.Registry, repos []git.Repository, opts git.Rai return nil } - 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.go b/internal/git/network_git.go index 2c7a917..a92ae87 100644 --- a/internal/git/network_git.go +++ b/internal/git/network_git.go @@ -77,6 +77,12 @@ func RunNetworkGit(parent context.Context, cmd *exec.Cmd, timeoutSec int) ([]byt return combined.Bytes(), runCtx.Err() } 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 diff --git a/internal/git/network_git_test.go b/internal/git/network_git_test.go index acf8677..d367b60 100644 --- a/internal/git/network_git_test.go +++ b/internal/git/network_git_test.go @@ -80,6 +80,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") 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)