From f56c3a780f046ddb36d7d4a1f1d775bf7f76a2f4 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Fri, 17 Apr 2026 03:19:02 -0400 Subject: [PATCH 1/3] feat(cmd): parallel default run with streaming scan - Replace blocking scan+process with runRainDefaultStream (git-fire-style): cancellable scan, upsert/filter pipeline, FetchWorkers pool, atomic totals - Per-repo output via runRainOnRepo with [N/M] headers and mutex-serialized blocks - Scan progress ticker (2s), post-run TTY scan-wait or non-interactive cancel - Tests: streaming integration, helper coverage, fetchFailureMessage/weather/outcome/buildKnownPaths/upsert --- cmd/root.go | 518 +++++++++++++++++++++++++++++++++++------------ cmd/root_test.go | 496 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 888 insertions(+), 126 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 4f1e492..0edbcdc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,12 +6,20 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" + "os/signal" "path/filepath" + "strconv" "strings" + "sync" + "sync/atomic" + "syscall" "time" + "github.com/mattn/go-isatty" + "github.com/mattn/go-runewidth" "github.com/spf13/cobra" "github.com/git-rain/git-rain/internal/config" @@ -135,14 +143,12 @@ func runRain(_ *cobra.Command, _ []string) error { if rainNoScan { cfg.Global.DisableScan = true } - riskyMode := cfg.Global.RiskyMode || rainRisky - branchModeStr := cfg.Global.BranchMode if rainBranchMode != "" { branchModeStr = rainBranchMode } rainOpts := git.RainOptions{ - RiskyMode: riskyMode, + RiskyMode: cfg.Global.RiskyMode || rainRisky, BranchMode: git.ParseBranchSyncMode(branchModeStr), SyncTags: cfg.Global.SyncTags || rainSyncTags, FetchPrune: cfg.Global.FetchPrune, @@ -205,66 +211,7 @@ func runRain(_ *cobra.Command, _ []string) error { return runRainTUIStream(cfg, reg, regPath, opts, rainOpts, fullSync) } - repos, err := git.ScanRepositories(opts) - if err != nil { - return fmt.Errorf("repository scan failed: %w", err) - } - - now := time.Now() - defaultMode := git.ParseMode(cfg.Global.DefaultMode) - for i, repo := range repos { - repos[i], _ = upsertRepoIntoRegistry(reg, repo, now, defaultMode) - } - saveRegistry(reg, regPath) - - active := make([]git.Repository, 0, len(repos)) - for _, repo := range repos { - absPath, absErr := filepath.Abs(repo.Path) - if absErr != nil { - active = append(active, repo) - continue - } - entry := reg.FindByPath(absPath) - if entry != nil && entry.Status == registry.StatusIgnored { - continue - } - active = append(active, repo) - } - if len(active) == 0 { - fmt.Println("No git repositories found.") - return nil - } - - fmt.Println("🌧️ Git Rain") - if fullSync { - if riskyMode { - fmt.Println("⚠️ Risky mode enabled: local-only commits may be realigned after backup branch creation") - } else { - fmt.Println("βœ“ Safe mode: local-only commits are preserved") - } - } else if rainFetchMainline { - fmt.Println("βœ“ Mainline fetch: mainline remote-tracking refs only (--fetch-mainline; --prune is opt-in)") - } else { - fmt.Println("βœ“ Default: git fetch --all per repo (remote-tracking refs only; --prune opt-in; --fetch-mainline for less; --sync to update locals)") - } - fmt.Println() - - totals := rainTotals{} - processRainRepositories(reg, active, rainOpts, 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 runRainDefaultStream(cfg, reg, regPath, opts, rainOpts, fullSync) } // weatherSymbol returns the terminal symbol for a branch outcome. @@ -325,6 +272,12 @@ func outcomeLabel(outcome string) string { // printRainBranchResults prints one line per branch (mainline fetch or full sync). func printRainBranchResults(branches []git.RainBranchResult, showBackup bool) { + writeRainBranchResults(os.Stdout, branches, showBackup) +} + +// writeRainBranchResults writes branch result lines to out (used by streaming +// per-repo block builders so concurrent workers can serialize their output). +func writeRainBranchResults(out io.Writer, branches []git.RainBranchResult, showBackup bool) { for _, br := range branches { symbol := weatherSymbol(br.Outcome) line := fmt.Sprintf(" %s %s", symbol, br.Branch) @@ -338,7 +291,7 @@ func printRainBranchResults(branches []git.RainBranchResult, showBackup bool) { if showBackup && br.BackupBranch != "" { line += " (backup: " + br.BackupBranch + ")" } - fmt.Println(line) + fmt.Fprintln(out, line) } } @@ -363,76 +316,100 @@ type rainTotals struct { failed int } -// processRainRepositories runs fetch or full rain for each repository and accumulates outcome counts. +// 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 _, repo := range repos { - fmt.Printf(" %s\n", repo.Name) + for i, repo := range repos { + var buf strings.Builder + delta := runRainOnRepo(reg, repo, rainOpts, fullSync, i+1, len(repos), &buf) + _, _ = io.WriteString(os.Stdout, buf.String()) + totals.updated += delta.updated + totals.skipped += delta.skipped + totals.frozen += delta.frozen + totals.failed += delta.failed + } +} - absRepo, absErr := filepath.Abs(repo.Path) - if absErr != nil { - absRepo = repo.Path - } - repoOpts, pruneErr := applyRepoFetchPrune(repo.Path, rainOpts, absRepo, reg) - if pruneErr != nil { - fmt.Printf(" βœ— failed: %s\n\n", safety.SanitizeText(pruneErr.Error())) - totals.failed++ - continue - } +// runRainOnRepo runs the rain operation for a single repository, writing its +// entire output block (header + branch lines + trailing blank line) into out. +// totalStr is rendered as "[N/M] reponame"; pass "?" for M when the total is +// not yet known (streaming scan still in flight). Returns the totals delta. +func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.RainOptions, fullSync bool, current, total int, out io.Writer) rainTotals { + totalStr := "?" + if total > 0 { + totalStr = strconv.Itoa(total) + } + fmt.Fprintf(out, " [%d/%s] %s\n", current, totalStr, repo.Name) - if !fullSync && !rainFetchMainline { - if fetchErr := fetchOnly(repo.Path, repoOpts); fetchErr != nil { - fmt.Printf(" ❄ (fetch --all): %s\n\n", - safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))) - totals.frozen++ - continue - } - fmt.Println(" ↓ fetched") - fmt.Println() - totals.updated++ - continue - } + delta := rainTotals{} - if !fullSync { - res, fetchErr := git.MainlineFetchRemotes(repo.Path, repoOpts) - if fetchErr != nil { - fmt.Printf(" βœ— failed: %s\n\n", safety.SanitizeText(fetchErr.Error())) - totals.failed++ - continue - } - if len(res.Branches) == 0 { - fmt.Println(" Β· no mainline branches to fetch") - fmt.Println() - continue - } - printRainBranchResults(res.Branches, false) - fmt.Println() - totals.updated += res.Updated - totals.skipped += res.Skipped - totals.frozen += res.Frozen - totals.failed += res.Failed - continue - } + absRepo, absErr := filepath.Abs(repo.Path) + if absErr != nil { + absRepo = repo.Path + } + repoOpts, pruneErr := applyRepoFetchPrune(repo.Path, rainOpts, absRepo, reg) + if pruneErr != nil { + fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(pruneErr.Error())) + delta.failed++ + return delta + } - res, rainErr := git.RainRepository(repo.Path, repoOpts) - if rainErr != nil { - fmt.Printf(" βœ— failed: %s\n\n", safety.SanitizeText(rainErr.Error())) - totals.failed++ - continue + if !fullSync && !rainFetchMainline { + if fetchErr := fetchOnly(repo.Path, repoOpts); fetchErr != nil { + fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", + safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))) + delta.frozen++ + return delta + } + fmt.Fprintln(out, " ↓ fetched") + fmt.Fprintln(out) + delta.updated++ + return delta + } + + if !fullSync { + res, fetchErr := git.MainlineFetchRemotes(repo.Path, repoOpts) + if fetchErr != nil { + fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(fetchErr.Error())) + delta.failed++ + return delta } if len(res.Branches) == 0 { - fmt.Println(" Β· no local branches") - fmt.Println() - continue + fmt.Fprintln(out, " Β· no mainline branches to fetch") + fmt.Fprintln(out) + return delta } - - printRainBranchResults(res.Branches, true) - fmt.Println() - - totals.updated += res.Updated - totals.skipped += res.Skipped - totals.frozen += res.Frozen - totals.failed += res.Failed - } + writeRainBranchResults(out, res.Branches, false) + fmt.Fprintln(out) + delta.updated += res.Updated + delta.skipped += res.Skipped + delta.frozen += res.Frozen + delta.failed += res.Failed + return delta + } + + res, rainErr := git.RainRepository(repo.Path, repoOpts) + if rainErr != nil { + fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(rainErr.Error())) + delta.failed++ + return delta + } + if len(res.Branches) == 0 { + fmt.Fprintln(out, " Β· no local branches") + fmt.Fprintln(out) + return delta + } + + writeRainBranchResults(out, res.Branches, true) + fmt.Fprintln(out) + + delta.updated += res.Updated + delta.skipped += res.Skipped + delta.frozen += res.Frozen + delta.failed += res.Failed + return delta } func runDryRun(scanOpts git.ScanOptions, rainOpts git.RainOptions, fullSync bool, cfg *config.Config) error { @@ -595,6 +572,295 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string return runRainOnRepos(reg, selected, rainOpts, fullSync) } +// fetchWorkerCount returns the per-run parallel fetch worker count, clamped +// to at least 1. Falls back to the package default when the config value is +// non-positive (matches the loader's auto-fix behavior for fresh installs). +func fetchWorkerCount(cfgWorkers int) int { + if cfgWorkers <= 0 { + return config.DefaultFetchWorkers + } + return cfgWorkers +} + +// truncateScanProgressPath shortens path so its display width fits maxLen, +// preserving the tail (most-specific component) and prepending an ellipsis. +// Returns "" for empty input. When maxLen is too small for "...", falls back +// to a hard width-truncation. +func truncateScanProgressPath(path string, maxLen int) string { + if path == "" { + return "" + } + if maxLen <= 0 || runewidth.StringWidth(path) <= maxLen { + return path + } + const ellipsis = "..." + ellipsisWidth := runewidth.StringWidth(ellipsis) + if maxLen <= ellipsisWidth { + return runewidth.Truncate(path, maxLen, "") + } + remaining := maxLen - ellipsisWidth + runes := []rune(path) + start := len(runes) + width := 0 + for i := len(runes) - 1; i >= 0; i-- { + rw := runewidth.RuneWidth(runes[i]) + if width+rw > remaining { + break + } + width += rw + start = i + } + return ellipsis + string(runes[start:]) +} + +// scanProgressPathMaxLen returns the dynamic max display width for a scan +// progress path, based on $COLUMNS and the prefix already on the line. +func scanProgressPathMaxLen(prefix string) int { + const ( + fallback = 72 + minLen = 8 + ) + cols, err := strconv.Atoi(os.Getenv("COLUMNS")) + if err != nil || cols <= 0 { + return fallback + } + dynamic := cols - runewidth.StringWidth(prefix) + if dynamic < minLen { + return minLen + } + if dynamic > fallback { + return fallback + } + return dynamic +} + +// stdinInteractiveOK reports whether stdin is suitable for blocking prompts. +// Always false under CI / known automation env vars or GIT_RAIN_NON_INTERACTIVE. +func stdinInteractiveOK() bool { + if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" { + return false + } + if os.Getenv("GIT_RAIN_NON_INTERACTIVE") != "" { + return false + } + if _, err := os.Stdin.Stat(); err != nil { + return false + } + fd := os.Stdin.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +// runRainDefaultStream is the default live-run path for `git-rain` (no flags +// other than scope-narrowing ones). It pipelines scan -> registry upsert -> +// per-repo rain so that fetching starts as soon as the first repo is found, +// without waiting for the full filesystem scan to complete. +// +// Workers (cfg.Global.FetchWorkers, clamped >=1) consume repos from a buffered +// channel and write each repo's full output block atomically under a mutex so +// lines from concurrent repos never interleave. After all per-repo work +// finishes, an in-flight scan is either awaited (TTY: prompt the user) or +// cancelled (non-interactive). +func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, rainOpts git.RainOptions, fullSync bool) error { + fmt.Println("🌧️ Git Rain") + if fullSync { + if rainOpts.RiskyMode { + fmt.Println("⚠️ Risky mode enabled: local-only commits may be realigned after backup branch creation") + } else { + fmt.Println("βœ“ Safe mode: local-only commits are preserved") + } + } else if rainFetchMainline { + fmt.Println("βœ“ Mainline fetch: mainline remote-tracking refs only (--fetch-mainline; --prune is opt-in)") + } else { + fmt.Println("βœ“ Default: git fetch --all per repo (remote-tracking refs only; --prune opt-in; --fetch-mainline for less; --sync to update locals)") + } + fmt.Println() + + ctx, cancelScan := context.WithCancel(context.Background()) + defer cancelScan() + opts.Ctx = ctx + + folderProgress := make(chan string, 32) + opts.FolderProgress = folderProgress + + scanChan := make(chan git.Repository, opts.Workers) + repoChan := make(chan git.Repository, opts.Workers) + + var totalFound int64 + var scanErr error + scanDone := make(chan struct{}) + + go func() { + defer close(scanDone) + scanErr = git.ScanRepositoriesStream(opts, scanChan) + }() + + // Folder-progress ticker: emits the latest scanned path every 2s while the + // walk is running, so long scans aren't silent. Skipped when DisableScan + // because there is no walk to report on. + var lastFolder atomic.Pointer[string] + if !opts.DisableScan { + go func() { + for p := range folderProgress { + pp := p + lastFolder.Store(&pp) + } + }() + go func() { + tick := time.NewTicker(2 * time.Second) + defer tick.Stop() + const scanPrefix = " πŸ” Scanning… " + for { + select { + case <-scanDone: + return + case <-tick.C: + ptr := lastFolder.Load() + if ptr != nil && *ptr != "" { + maxPathLen := scanProgressPathMaxLen(scanPrefix) + fmt.Printf("%s%s\n", scanPrefix, truncateScanProgressPath(*ptr, maxPathLen)) + } + } + } + }() + } else { + go func() { + for range folderProgress { + } + }() + } + + // Upsert + filter goroutine: drains scanChan, registers/updates each repo, + // and forwards non-ignored repos to repoChan. Closes repoChan when the + // scan channel drains so workers exit naturally. + now := time.Now() + defaultMode := git.ParseMode(cfg.Global.DefaultMode) + upsertDone := make(chan struct{}) + go func() { + defer close(upsertDone) + defer close(repoChan) + for repo := range scanChan { + repo, include := upsertRepoIntoRegistry(reg, repo, now, defaultMode) + if !include { + continue + } + atomic.AddInt64(&totalFound, 1) + repo.Selected = true + repoChan <- repo + } + saveRegistry(reg, regPath) + }() + + // Worker pool consumes repos in parallel. printMu serializes per-repo + // output blocks so lines from concurrent repos can't interleave. + var ( + updated int64 + skipped int64 + frozen int64 + failed int64 + printMu sync.Mutex + seq int64 + ) + + workers := fetchWorkerCount(cfg.Global.FetchWorkers) + var workersWG sync.WaitGroup + for w := 0; w < workers; w++ { + workersWG.Add(1) + go func() { + defer workersWG.Done() + for repo := range repoChan { + current := int(atomic.AddInt64(&seq, 1)) + total := int(atomic.LoadInt64(&totalFound)) + // While the scan is still running, total may equal current + // or be slightly ahead; treat "0 or behind" as unknown ("?"). + if total < current { + total = 0 + } + + var buf strings.Builder + delta := runRainOnRepo(reg, repo, rainOpts, fullSync, current, total, &buf) + + printMu.Lock() + _, _ = io.WriteString(os.Stdout, buf.String()) + printMu.Unlock() + + atomic.AddInt64(&updated, int64(delta.updated)) + atomic.AddInt64(&skipped, int64(delta.skipped)) + atomic.AddInt64(&frozen, int64(delta.frozen)) + atomic.AddInt64(&failed, int64(delta.failed)) + } + }() + } + + workersWG.Wait() + <-upsertDone + + // If the scan is still running after all repos finished, wait or cancel. + select { + case <-scanDone: + default: + if stdinInteractiveOK() { + printMu.Lock() + fmt.Println() + fmt.Println("βœ… All repos done. Scan still running.") + fmt.Println(" Press Enter to wait for scan to finish, or Ctrl+C to stop scanning.") + printMu.Unlock() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + inputDone := make(chan struct{}) + go func() { + defer close(inputDone) + r := bufio.NewReader(os.Stdin) + _, _ = r.ReadString('\n') + }() + + select { + case <-sigCh: + fmt.Println("\nAborting scan...") + cancelScan() + case <-inputDone: + case <-scanDone: + } + signal.Stop(sigCh) + } else { + fmt.Println() + fmt.Println("βœ… All repos done. Scan still running β€” stopping scan (non-interactive).") + cancelScan() + } + <-scanDone + } + + if scanErr != nil && !errors.Is(scanErr, context.Canceled) { + fmt.Fprintf(os.Stderr, "warning: scan error: %s\n", safety.SanitizeText(scanErr.Error())) + } + + if atomic.LoadInt64(&seq) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + totals := rainTotals{ + updated: int(atomic.LoadInt64(&updated)), + skipped: int(atomic.LoadInt64(&skipped)), + frozen: int(atomic.LoadInt64(&frozen)), + 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 +} + // runRainOnRepos runs fetch or full rain on a pre-selected list of repos. func runRainOnRepos(reg *registry.Registry, repos []git.Repository, opts git.RainOptions, fullSync bool) error { fmt.Println("🌧️ Git Rain") diff --git a/cmd/root_test.go b/cmd/root_test.go index 8c3a05a..4ba334d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -8,8 +8,13 @@ import ( "path/filepath" "strings" "testing" + "time" testutil "github.com/git-fire/git-testkit" + + "github.com/git-rain/git-rain/internal/config" + "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/registry" ) func resetFlags() { @@ -243,6 +248,497 @@ func TestRunRain_RiskyFlagResetsLocalAheadBranch(t *testing.T) { } } +// makeFetchedRepo creates a git repo with a remote that has been pushed to, +// returning the repo for use in tests that need the default fetch path. +func makeFetchedRepo(t *testing.T, scenario *testutil.Scenario, name string) *testutil.ScenarioRepo { + t.Helper() + remote := scenario.CreateBareRepo(name + "-remote") + repo := scenario.CreateRepo(name). + WithRemote("origin", remote). + AddFile("a.txt", "v1\n"). + Commit("init") + repo.Push("origin", repo.GetDefaultBranch()) + return repo +} + +// cloneIntoScanRoot clones a bare remote into scanRoot/. Returns the +// clone path. Used to put multiple repos under one scan root without +// relocating testkit-managed paths (which live under per-call t.TempDir()s). +func cloneIntoScanRoot(t *testing.T, scanRoot, name, remotePath string) string { + t.Helper() + dst := filepath.Join(scanRoot, name) + out, err := exec.Command("git", "clone", remotePath, dst).CombinedOutput() + if err != nil { + t.Fatalf("git clone %s into %s failed: %v\n%s", remotePath, dst, err, out) + } + testutil.RunGitCmd(t, dst, "config", "user.email", "test@example.com") + testutil.RunGitCmd(t, dst, "config", "user.name", "Test User") + return dst +} + +func TestRunRain_DefaultStream_MultiRepoParity(t *testing.T) { + tmpHome := t.TempDir() + setTestUserDirs(t, tmpHome) + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "1") + + scenario := testutil.NewScenario(t) + scanRoot := t.TempDir() + + for i := 0; i < 3; i++ { + seed := scenario.CreateRepo("seed-"+string(rune('a'+i))). + AddFile("a.txt", "v1\n"). + Commit("init") + remote := scenario.CreateBareRepo("remote-" + string(rune('a'+i))) + seed.WithRemote("origin", remote).Push("origin", seed.GetDefaultBranch()) + cloneIntoScanRoot(t, scanRoot, "stream-multi-"+string(rune('a'+i)), remote.Path()) + } + + resetFlags() + rainPath = scanRoot + + var runErr error + out := captureStdout(t, func() { + runErr = runRain(rootCmd, []string{}) + }) + if runErr != nil { + t.Fatalf("runRain default stream error = %v\n%s", runErr, out) + } + + // All three repos should have a "fetched" line. + if got := strings.Count(out, "↓ fetched"); got != 3 { + t.Fatalf("want exactly 3 'fetched' lines, got %d. output:\n%s", got, out) + } + + // Each repo's [N/M] header should be on its own line and immediately + // followed by its fetched line β€” that confirms the printMu serialization + // kept the per-repo block contiguous. + lines := strings.Split(out, "\n") + headerCount := 0 + for i, line := range lines { + if !strings.Contains(line, "] stream-multi-") { + continue + } + headerCount++ + if i+1 >= len(lines) || !strings.Contains(lines[i+1], "↓ fetched") { + t.Fatalf("expected fetched line directly after header %q; got %q. full output:\n%s", + line, safeIndex(lines, i+1), out) + } + } + if headerCount != 3 { + t.Fatalf("want 3 [N/M] headers, got %d. output:\n%s", headerCount, out) + } + + if !strings.Contains(out, "rain delivered") { + t.Fatalf("expected summary line, got:\n%s", out) + } +} + +func safeIndex(s []string, i int) string { + if i < 0 || i >= len(s) { + return "" + } + return s[i] +} + +func TestRunRain_DefaultStream_NoScanHydratesRegistryOnly(t *testing.T) { + tmpHome := t.TempDir() + setTestUserDirs(t, tmpHome) + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "1") + + scenario := testutil.NewScenario(t) + repos := []*testutil.ScenarioRepo{ + makeFetchedRepo(t, scenario, "noscan-a"), + makeFetchedRepo(t, scenario, "noscan-b"), + } + + regPath, err := registry.DefaultRegistryPath() + if err != nil { + t.Fatalf("DefaultRegistryPath: %v", err) + } + reg := ®istry.Registry{} + now := time.Now() + for _, r := range repos { + abs, absErr := filepath.Abs(r.Path()) + if absErr != nil { + t.Fatalf("abs: %v", absErr) + } + reg.Upsert(registry.RegistryEntry{ + Path: abs, + Name: filepath.Base(abs), + Status: registry.StatusActive, + Mode: git.ModeSyncDefault.String(), + AddedAt: now, + LastSeen: now, + }) + } + if err := registry.Save(reg, regPath); err != nil { + t.Fatalf("registry.Save: %v", err) + } + + resetFlags() + rainNoScan = true + rainPath = t.TempDir() // empty dir; no-scan should ignore it anyway + + var runErr error + out := captureStdout(t, func() { + runErr = runRain(rootCmd, []string{}) + }) + if runErr != nil { + t.Fatalf("runRain --no-scan error = %v\n%s", runErr, out) + } + + if !strings.Contains(out, "Rain scanning disabled") { + t.Fatalf("expected scanning-disabled banner, got:\n%s", out) + } + if got := strings.Count(out, "↓ fetched"); got != 2 { + t.Fatalf("want 2 fetched lines for the 2 known repos, got %d. output:\n%s", got, out) + } + if strings.Contains(out, "πŸ” Scanning…") { + t.Fatalf("scan progress line should be absent under --no-scan. output:\n%s", out) + } +} + +func TestRunRain_DefaultStream_EmptyScanRoot(t *testing.T) { + tmpHome := t.TempDir() + setTestUserDirs(t, tmpHome) + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "1") + + resetFlags() + rainPath = t.TempDir() + + var runErr error + out := captureStdout(t, func() { + runErr = runRain(rootCmd, []string{}) + }) + if runErr != nil { + t.Fatalf("runRain empty-scan error = %v\n%s", runErr, out) + } + if !strings.Contains(out, "No git repositories found.") { + t.Fatalf("expected 'No git repositories found.' in output, got:\n%s", out) + } + if strings.Contains(out, "rain delivered") { + t.Fatalf("should not print summary when no repos. output:\n%s", out) + } +} + +func TestFetchWorkerCount(t *testing.T) { + tests := []struct { + name string + in int + want int + }{ + {"zero falls back to default", 0, config.DefaultFetchWorkers}, + {"negative falls back to default", -3, config.DefaultFetchWorkers}, + {"positive is preserved", 7, 7}, + {"one is preserved", 1, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fetchWorkerCount(tt.in); got != tt.want { + t.Fatalf("fetchWorkerCount(%d) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +func TestRunRain_DefaultStream_ZeroFetchWorkersStillRuns(t *testing.T) { + tmpHome := t.TempDir() + setTestUserDirs(t, tmpHome) + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "1") + + scenario := testutil.NewScenario(t) + repo := makeFetchedRepo(t, scenario, "zero-workers") + + cfgDir := filepath.Join(tmpHome, ".config", "git-rain") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatalf("mkdir config: %v", err) + } + cfgPath := filepath.Join(cfgDir, "config.toml") + cfgBody := `[global] +scan_path = "." +fetch_workers = 0 +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + resetFlags() + rainConfigFile = cfgPath + rainPath = filepath.Dir(repo.Path()) + + var runErr error + out := captureStdout(t, func() { + runErr = runRain(rootCmd, []string{}) + }) + if runErr != nil { + t.Fatalf("runRain zero-workers error = %v\n%s", runErr, out) + } + if !strings.Contains(out, "↓ fetched") { + t.Fatalf("expected fetched line even when fetch_workers=0; got:\n%s", out) + } +} + +func TestFetchFailureMessage(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"authentication", "Authentication failed for git@github.com", "could not authenticate with remote β€” check your credentials and try again"}, + {"permission denied", "git@github.com: Permission denied (publickey)", "could not authenticate with remote β€” check your credentials and try again"}, + {"could not read", "fatal: could not read from remote", "could not authenticate with remote β€” check your credentials and try again"}, + {"401", "fatal: HTTP 401: Unauthorized", "could not authenticate with remote β€” check your credentials and try again"}, + {"403", "fatal: HTTP 403 forbidden", "could not authenticate with remote β€” check your credentials and try again"}, + {"could not resolve", "fatal: unable to access ...: Could not resolve host", "could not reach remote β€” check your network and try again"}, + {"connection", "fatal: unable to access: connection refused", "could not reach remote β€” check your network and try again"}, + {"timed out", "fatal: connection timed out", "could not reach remote β€” check your network and try again"}, + {"network", "transient network failure", "could not reach remote β€” check your network and try again"}, + {"generic fallback", "something else went wrong", "fetch did not complete β€” try again when the remote is reachable"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fetchFailureMessage(tt.in); got != tt.want { + t.Fatalf("fetchFailureMessage(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestWeatherSymbol(t *testing.T) { + tests := []struct { + outcome string + want string + }{ + {git.RainOutcomeUpdated, "↓"}, + {git.RainOutcomeUpdatedRisky, "⚑"}, + {git.RainOutcomeUpToDate, "Β·"}, + {git.RainOutcomeFetched, "↓"}, + {git.RainOutcomeFrozen, "❄"}, + {git.RainOutcomeFailed, "βœ—"}, + {git.RainOutcomeSkippedNoUpstream, "~"}, + {git.RainOutcomeSkippedAmbiguousUpstream, "~"}, + {git.RainOutcomeSkippedUpstreamMissing, "~"}, + {git.RainOutcomeSkippedCheckedOut, "~"}, + {git.RainOutcomeSkippedLocalAhead, "~"}, + {git.RainOutcomeSkippedDiverged, "~"}, + {git.RainOutcomeSkippedUnsafeMerge, "~"}, + {git.RainOutcomeSkippedUnsafeDirty, "~"}, + {"unknown-outcome", "~"}, + } + for _, tt := range tests { + t.Run(tt.outcome, func(t *testing.T) { + if got := weatherSymbol(tt.outcome); got != tt.want { + t.Fatalf("weatherSymbol(%q) = %q, want %q", tt.outcome, got, tt.want) + } + }) + } +} + +func TestOutcomeLabel(t *testing.T) { + tests := []struct { + outcome string + want string + }{ + {git.RainOutcomeUpdated, "synced"}, + {git.RainOutcomeUpdatedRisky, "realigned"}, + {git.RainOutcomeUpToDate, "current"}, + {git.RainOutcomeFetched, "fetched"}, + {git.RainOutcomeFrozen, "frozen"}, + {git.RainOutcomeFailed, "failed"}, + {git.RainOutcomeSkippedNoUpstream, "no upstream"}, + {git.RainOutcomeSkippedAmbiguousUpstream, "ambiguous upstream"}, + {git.RainOutcomeSkippedUpstreamMissing, "upstream missing"}, + {git.RainOutcomeSkippedCheckedOut, "checked out elsewhere"}, + {git.RainOutcomeSkippedLocalAhead, "local ahead"}, + {git.RainOutcomeSkippedDiverged, "diverged"}, + {git.RainOutcomeSkippedUnsafeMerge, "unsafe merge"}, + {git.RainOutcomeSkippedUnsafeDirty, "dirty worktree"}, + {"made-up", "made-up"}, + } + for _, tt := range tests { + t.Run(tt.outcome, func(t *testing.T) { + if got := outcomeLabel(tt.outcome); got != tt.want { + t.Fatalf("outcomeLabel(%q) = %q, want %q", tt.outcome, got, tt.want) + } + }) + } +} + +func TestBuildKnownPaths(t *testing.T) { + rescanTrue := true + rescanFalse := false + + tmp := t.TempDir() + abs1 := filepath.Join(tmp, "abs1") + abs2 := filepath.Join(tmp, "abs2") + abs3 := filepath.Join(tmp, "abs3") + abs4 := filepath.Join(tmp, "abs4") + abs5 := filepath.Join(tmp, "abs5") + + reg := ®istry.Registry{ + Repos: []registry.RegistryEntry{ + {Path: abs1, Status: registry.StatusActive}, + {Path: abs2, Status: registry.StatusMissing}, + {Path: abs3, Status: registry.StatusIgnored}, + {Path: abs4, Status: ""}, + {Path: abs5, Status: registry.StatusActive, RescanSubmodules: &rescanFalse}, + {Path: filepath.Join(tmp, "abs6"), Status: registry.StatusActive, RescanSubmodules: &rescanTrue}, + }, + } + + got := buildKnownPaths(reg, false) + + for _, p := range []string{abs1, abs2, abs4, abs5} { + if _, ok := got[p]; !ok { + t.Errorf("expected %s in known paths", p) + } + } + if _, ok := got[abs3]; ok { + t.Errorf("ignored entry %s should not be in known paths", abs3) + } + if got[abs5] != false { + t.Errorf("entry-level rescan=false should override global=false (still false), got %v", got[abs5]) + } + if got[filepath.Join(tmp, "abs6")] != true { + t.Errorf("entry-level rescan=true should override global=false, got %v", got[filepath.Join(tmp, "abs6")]) + } + + // Now flip the global; the per-entry override on abs5 should still pin to false. + gotGlobalOn := buildKnownPaths(reg, true) + if gotGlobalOn[abs1] != true { + t.Errorf("global=true should propagate to entries without override, got abs1=%v", gotGlobalOn[abs1]) + } + if gotGlobalOn[abs5] != false { + t.Errorf("global=true must NOT override per-entry rescan=false, got abs5=%v", gotGlobalOn[abs5]) + } +} + +func TestUpsertRepoIntoRegistry(t *testing.T) { + now := time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC) + tmp := t.TempDir() + pathA := filepath.Join(tmp, "a") + pathB := filepath.Join(tmp, "b") + pathIgnored := filepath.Join(tmp, "ignored") + + reg := ®istry.Registry{ + Repos: []registry.RegistryEntry{ + {Path: pathB, Name: "b", Status: registry.StatusActive, Mode: git.ModeSyncAll.String(), AddedAt: now.Add(-time.Hour), LastSeen: now.Add(-time.Hour)}, + {Path: pathIgnored, Name: "ignored", Status: registry.StatusIgnored, AddedAt: now.Add(-time.Hour)}, + }, + } + + // New entry should get default mode and IsNewRegistryEntry=true. + gotA, includeA := upsertRepoIntoRegistry(reg, git.Repository{Path: pathA, Name: "a"}, now, git.ModeSyncDefault) + if !includeA { + t.Error("new entry should be included") + } + if !gotA.IsNewRegistryEntry { + t.Error("new entry should report IsNewRegistryEntry=true") + } + if gotA.Mode != git.ModeSyncDefault { + t.Errorf("new entry mode = %v, want %v", gotA.Mode, git.ModeSyncDefault) + } + if reg.FindByPath(pathA) == nil { + t.Error("new entry should be persisted in registry") + } + + // Existing entry should adopt its registered mode and not be new. + gotB, includeB := upsertRepoIntoRegistry(reg, git.Repository{Path: pathB, Name: "b"}, now, git.ModeSyncDefault) + if !includeB { + t.Error("active entry should be included") + } + if gotB.IsNewRegistryEntry { + t.Error("existing entry should not be marked new") + } + if gotB.Mode != git.ModeSyncAll { + t.Errorf("existing entry should preserve registered mode (%v), got %v", git.ModeSyncAll, gotB.Mode) + } + + // Ignored entry should be excluded. + _, includeIgn := upsertRepoIntoRegistry(reg, git.Repository{Path: pathIgnored, Name: "ignored"}, now, git.ModeSyncDefault) + if includeIgn { + t.Error("ignored entry should not be included") + } + if e := reg.FindByPath(pathIgnored); e == nil || e.Status != registry.StatusIgnored { + t.Error("ignored entry should remain ignored after upsert") + } +} + +func TestTruncateScanProgressPath(t *testing.T) { + tests := []struct { + name string + path string + maxLen int + want string + }{ + {"empty", "", 10, ""}, + {"shorter than max", "/a/b", 10, "/a/b"}, + {"equal to max", "/abcdefghi", 10, "/abcdefghi"}, + {"truncated with ellipsis", "/very/long/nested/path/to/repo", 12, "...h/to/repo"}, + {"maxLen too small for ellipsis", "/abc/def", 2, "/a"}, + {"maxLen=0 returns full path", "/keep", 0, "/keep"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateScanProgressPath(tt.path, tt.maxLen) + if got != tt.want { + t.Fatalf("truncateScanProgressPath(%q, %d) = %q, want %q", tt.path, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestScanProgressPathMaxLen(t *testing.T) { + t.Run("no COLUMNS falls back", func(t *testing.T) { + t.Setenv("COLUMNS", "") + got := scanProgressPathMaxLen("prefix") + if got != 72 { + t.Fatalf("fallback expected 72, got %d", got) + } + }) + t.Run("dynamic shrinks below fallback", func(t *testing.T) { + t.Setenv("COLUMNS", "40") + got := scanProgressPathMaxLen("prefix-") + // 40 - 7 = 33 + if got != 33 { + t.Fatalf("dynamic want 33, got %d", got) + } + }) + t.Run("dynamic clamped to minLen", func(t *testing.T) { + t.Setenv("COLUMNS", "5") + got := scanProgressPathMaxLen("longprefix") + if got != 8 { + t.Fatalf("min-clamped want 8, got %d", got) + } + }) + t.Run("dynamic capped at fallback", func(t *testing.T) { + t.Setenv("COLUMNS", "200") + got := scanProgressPathMaxLen("p") + if got != 72 { + t.Fatalf("capped want 72, got %d", got) + } + }) +} + +func TestStdinInteractiveOK(t *testing.T) { + t.Run("CI false", func(t *testing.T) { + t.Setenv("CI", "1") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "") + if stdinInteractiveOK() { + t.Fatal("CI=1 must disable stdin interactivity") + } + }) + t.Run("GIT_RAIN_NON_INTERACTIVE false", func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GIT_RAIN_NON_INTERACTIVE", "1") + if stdinInteractiveOK() { + t.Fatal("GIT_RAIN_NON_INTERACTIVE=1 must disable stdin interactivity") + } + }) +} + func hasRainBackupBranch(t *testing.T, repoPath string) bool { t.Helper() cmd := exec.Command("git", "branch", "--format=%(refname:short)") From b541365f8965125428476a924e89a06c1578b4d7 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Fri, 17 Apr 2026 03:21:09 -0400 Subject: [PATCH 2/3] fix(lint): silence Fprint* errcheck and drop unused printRainBranchResults --- cmd/root.go | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 0edbcdc..6fdd40c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -270,11 +270,6 @@ func outcomeLabel(outcome string) string { } } -// printRainBranchResults prints one line per branch (mainline fetch or full sync). -func printRainBranchResults(branches []git.RainBranchResult, showBackup bool) { - writeRainBranchResults(os.Stdout, branches, showBackup) -} - // writeRainBranchResults writes branch result lines to out (used by streaming // per-repo block builders so concurrent workers can serialize their output). func writeRainBranchResults(out io.Writer, branches []git.RainBranchResult, showBackup bool) { @@ -291,7 +286,7 @@ func writeRainBranchResults(out io.Writer, branches []git.RainBranchResult, show if showBackup && br.BackupBranch != "" { line += " (backup: " + br.BackupBranch + ")" } - fmt.Fprintln(out, line) + _, _ = fmt.Fprintln(out, line) } } @@ -341,7 +336,7 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai if total > 0 { totalStr = strconv.Itoa(total) } - fmt.Fprintf(out, " [%d/%s] %s\n", current, totalStr, repo.Name) + _, _ = fmt.Fprintf(out, " [%d/%s] %s\n", current, totalStr, repo.Name) delta := rainTotals{} @@ -351,20 +346,20 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai } repoOpts, pruneErr := applyRepoFetchPrune(repo.Path, rainOpts, absRepo, reg) if pruneErr != nil { - fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(pruneErr.Error())) + _, _ = fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(pruneErr.Error())) delta.failed++ return delta } if !fullSync && !rainFetchMainline { if fetchErr := fetchOnly(repo.Path, repoOpts); fetchErr != nil { - fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", + _, _ = fmt.Fprintf(out, " ❄ (fetch --all): %s\n\n", safety.SanitizeText(fetchFailureMessage(fetchErr.Error()))) delta.frozen++ return delta } - fmt.Fprintln(out, " ↓ fetched") - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, " ↓ fetched") + _, _ = fmt.Fprintln(out) delta.updated++ return delta } @@ -372,17 +367,17 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai if !fullSync { res, fetchErr := git.MainlineFetchRemotes(repo.Path, repoOpts) if fetchErr != nil { - fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(fetchErr.Error())) + _, _ = fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(fetchErr.Error())) delta.failed++ return delta } if len(res.Branches) == 0 { - fmt.Fprintln(out, " Β· no mainline branches to fetch") - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, " Β· no mainline branches to fetch") + _, _ = fmt.Fprintln(out) return delta } writeRainBranchResults(out, res.Branches, false) - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out) delta.updated += res.Updated delta.skipped += res.Skipped delta.frozen += res.Frozen @@ -392,18 +387,18 @@ func runRainOnRepo(reg *registry.Registry, repo git.Repository, rainOpts git.Rai res, rainErr := git.RainRepository(repo.Path, repoOpts) if rainErr != nil { - fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(rainErr.Error())) + _, _ = fmt.Fprintf(out, " βœ— failed: %s\n\n", safety.SanitizeText(rainErr.Error())) delta.failed++ return delta } if len(res.Branches) == 0 { - fmt.Fprintln(out, " Β· no local branches") - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, " Β· no local branches") + _, _ = fmt.Fprintln(out) return delta } writeRainBranchResults(out, res.Branches, true) - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out) delta.updated += res.Updated delta.skipped += res.Skipped From cfec80b7e95288202907395a36fd16972e9443f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 07:36:41 +0000 Subject: [PATCH 3/3] fix(default stream): remove dead scan-wait block and sync ticker output Drop unreachable post-worker scan prompt (scan always finishes before repoChan closes). Serialize folder-progress lines with printMu like worker output. Await scanDone before reading scanErr to avoid a race. Co-authored-by: Ben Schellenberger --- cmd/root.go | 52 +++++++++------------------------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 6fdd40c..09cbdf9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,13 +9,11 @@ import ( "io" "os" "os/exec" - "os/signal" "path/filepath" "strconv" "strings" "sync" "sync/atomic" - "syscall" "time" "github.com/mattn/go-isatty" @@ -652,9 +650,8 @@ func stdinInteractiveOK() bool { // // Workers (cfg.Global.FetchWorkers, clamped >=1) consume repos from a buffered // channel and write each repo's full output block atomically under a mutex so -// lines from concurrent repos never interleave. After all per-repo work -// finishes, an in-flight scan is either awaited (TTY: prompt the user) or -// cancelled (non-interactive). +// lines from concurrent repos never interleave. The folder-progress ticker +// uses the same mutex so scan status lines cannot interleave with repo output. func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, rainOpts git.RainOptions, fullSync bool) error { fmt.Println("🌧️ Git Rain") if fullSync { @@ -680,6 +677,8 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st scanChan := make(chan git.Repository, opts.Workers) repoChan := make(chan git.Repository, opts.Workers) + var printMu sync.Mutex + var totalFound int64 var scanErr error scanDone := make(chan struct{}) @@ -712,7 +711,10 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st ptr := lastFolder.Load() if ptr != nil && *ptr != "" { maxPathLen := scanProgressPathMaxLen(scanPrefix) - fmt.Printf("%s%s\n", scanPrefix, truncateScanProgressPath(*ptr, maxPathLen)) + line := fmt.Sprintf("%s%s\n", scanPrefix, truncateScanProgressPath(*ptr, maxPathLen)) + printMu.Lock() + _, _ = io.WriteString(os.Stdout, line) + printMu.Unlock() } } } @@ -752,7 +754,6 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st skipped int64 frozen int64 failed int64 - printMu sync.Mutex seq int64 ) @@ -788,42 +789,7 @@ func runRainDefaultStream(cfg *config.Config, reg *registry.Registry, regPath st workersWG.Wait() <-upsertDone - - // If the scan is still running after all repos finished, wait or cancel. - select { - case <-scanDone: - default: - if stdinInteractiveOK() { - printMu.Lock() - fmt.Println() - fmt.Println("βœ… All repos done. Scan still running.") - fmt.Println(" Press Enter to wait for scan to finish, or Ctrl+C to stop scanning.") - printMu.Unlock() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - inputDone := make(chan struct{}) - go func() { - defer close(inputDone) - r := bufio.NewReader(os.Stdin) - _, _ = r.ReadString('\n') - }() - - select { - case <-sigCh: - fmt.Println("\nAborting scan...") - cancelScan() - case <-inputDone: - case <-scanDone: - } - signal.Stop(sigCh) - } else { - fmt.Println() - fmt.Println("βœ… All repos done. Scan still running β€” stopping scan (non-interactive).") - cancelScan() - } - <-scanDone - } + <-scanDone if scanErr != nil && !errors.Is(scanErr, context.Canceled) { fmt.Fprintf(os.Stderr, "warning: scan error: %s\n", safety.SanitizeText(scanErr.Error()))