Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 48 additions & 27 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
msg := safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))
if emitHuman {
_, _ = fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", msg)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return printRainSummary(totals, rainOpts.RunCtx)
}

// runRainOnRepos runs fetch or full rain on a pre-selected list of repos.
Expand Down Expand Up @@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

func applyRepoFetchPrune(repoPath string, base git.RainOptions, absPath string, reg *registry.Registry) (git.RainOptions, error) {
Expand Down
6 changes: 6 additions & 0 deletions internal/git/network_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/git/network_git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions internal/git/rain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
// 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)
Expand Down
Loading