diff --git a/main.go b/main.go index 0089d5f..0e949d4 100644 --- a/main.go +++ b/main.go @@ -484,6 +484,10 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in defer wg.Done() for next := range work { r := next.repo + // Counted active before any skip path can record: finished() + // decrements again, so an early exit that skipped started() + // would drive the active count negative. + live.update(func() { live.started(); live.draw() }) if !r.Eligible { record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason}) continue @@ -503,18 +507,17 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in } if !quiet { outputMu.Lock() - live.clear() + live.update(func() { live.clear() }) fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch) outputMu.Unlock() } - live.update(func() { live.started(); live.draw() }) started := time.Now() report, err := syncRepositoryWith(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries, syncOptions{CreateMissing: createMissing}) message := report.Message duration := time.Since(started).Round(time.Millisecond).String() if !quiet { outputMu.Lock() - live.clear() + live.update(func() { live.clear() }) state := "DONE" if err != nil { state = "FAIL" diff --git a/main_test.go b/main_test.go index 106e7d2..1d68033 100644 --- a/main_test.go +++ b/main_test.go @@ -268,3 +268,48 @@ func TestSyncOutputToBufferHasNoCarriageReturns(t *testing.T) { t.Fatalf("run did not print a summary:\n%q", stdout.String()) } } + +// A repository skipped before it ever syncs must still be counted active and +// then finished. Counting it as active only after the skip checks left the +// running "active" total negative, because finished() decrements it regardless. +func TestLiveProgressBalancesSkippedRepositories(t *testing.T) { + root := t.TempDir() + var repos []repo + for _, name := range []string{"blocked-a", "blocked-b", "blocked-c"} { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + // Not Eligible, so the worker takes the early-exit skip path. + repos = append(repos, repo{Path: path, RelativePath: name, BlockReason: "no remotes"}) + } + + var mu sync.Mutex + buf := &bytes.Buffer{} + live := &liveProgress{w: buf, mu: &mu, total: len(repos), counts: map[string]int{}} + results := syncSelectedWithProgress(root, repos, repos, 2, time.Minute, io.Discard, true, false, false, live, nil) + + if len(results) != len(repos) { + t.Fatalf("results = %d, want %d", len(results), len(repos)) + } + for _, result := range results { + if !result.Skipped { + t.Fatalf("%s was not skipped: %+v", result.Path, result) + } + } + if live.done != len(repos) { + t.Fatalf("done = %d, want %d", live.done, len(repos)) + } + if live.active != 0 { + t.Fatalf("active = %d after every repository finished, want 0", live.active) + } + if live.skipped != len(repos) { + t.Fatalf("skipped = %d, want %d", live.skipped, len(repos)) + } + // Intermediate frames legitimately report active repositories; only the + // last one must be settled. + frames := strings.Split(buf.String(), "\r") + if last := frames[len(frames)-1]; strings.Contains(last, "active") { + t.Fatalf("a settled run still reports active repositories: %q", last) + } +}