ktstr/cli/
progress.rs

1//! Progress bars for kernel fetches (tarball download + git clone).
2//!
3//! Two bar types, both rendered through a single
4//! [`indicatif::MultiProgress`] so concurrent fetches (the parallel
5//! `cargo ktstr test --kernel A --kernel B` resolve) never garble
6//! each other's terminal output, and both degrading to a no-op when
7//! stderr is not a TTY (CI, piped output) — the same contract as
8//! [`crate::cli::Spinner`].
9//!
10//! - [`FetchProgress`] — the group handle. One per resolve operation,
11//!   shared across the rayon workers by `&` (the inner
12//!   [`indicatif::MultiProgress`] is `Send + Sync`). Hands out child
13//!   bars and replaces the single-instance [`crate::cli::Spinner`] the
14//!   download path previously wrapped (a `Spinner` cannot host
15//!   concurrent bars). The build phase renders through this group too:
16//!   `kernel_build_pipeline` takes a group and draws the configure /
17//!   build / `compile_commands.json` phases as `step_bar` spinners. So
18//!   no code path runs a standalone `Spinner` alongside a
19//!   live `MultiProgress`, and the parallel resolve's concurrent builds
20//!   share the one group rather than racing the process-global
21//!   `SPINNER_ACTIVE` guard. The only remaining `Spinner` is
22//!   `auto_download_kernel`'s brief version-fetch, which always finishes
23//!   before any group bar is created.
24//! - [`GroupBar`] — a determinate byte bar for tarball downloads:
25//!   transfer rate and ETA derived from `Content-Length`. Falls back
26//!   to a live byte counter (rate, no ETA) when the response carries
27//!   no `Content-Length`.
28//! - [`CloneProgress`] — a determinate object/file bar for git clones,
29//!   driven by polling gix's prodash progress tree via
30//!   `tree::Root::sorted_snapshot`. Shows a real bar + ETA whenever gix
31//!   reports a bounded total — resolving deltas and checkout files
32//!   always do; the receiving/read-pack phase does only when the server
33//!   advertises a pack size (often unknown for shallow smart-HTTP
34//!   clones) — and a spinner otherwise (negotiation, or an unadvertised
35//!   pack size). Single renderer (indicatif), zero new dependencies —
36//!   prodash's own line renderer is not compiled in this dependency
37//!   graph, so the tree is read rather than rendered by prodash.
38
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::thread::JoinHandle;
42use std::time::Duration;
43
44use gix::progress::prodash::progress::{Key, Task};
45use gix::progress::tree::{Item, Root};
46use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
47
48use crate::cli::stderr_color;
49
50/// Frame cadence shared by the bars' steady tick and the gix poll
51/// loop. The poll loop samples the prodash tree at this rate — a
52/// display ticker, not an evented wait: prodash exposes no
53/// "counter advanced" notification, only the pull-based
54/// `sorted_snapshot`, so a fixed-cadence sample (the same approach
55/// prodash's own renderer takes via `frames_per_second`) is the
56/// correct shape here.
57const TICK: Duration = Duration::from_millis(100);
58
59/// Determinate byte-download template: spinner, label, bar, bytes,
60/// rate, ETA. `bytes`/`total_bytes`/`bytes_per_sec` render in binary
61/// units (MiB/KiB); `eta` is `Content-Length`-derived.
62const DOWNLOAD_TEMPLATE: &str =
63    "{spinner:.green} {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})";
64
65/// Byte-counter template for a download with no `Content-Length`: no
66/// total means no percent/ETA, but a live byte count + rate still
67/// shows real movement.
68const DOWNLOAD_TEMPLATE_NO_TOTAL: &str = "{spinner:.green} {msg} {bytes} ({bytes_per_sec})";
69
70/// Label-only spinner template for a non-quantifiable phase (kernel
71/// configure / build / compile_commands generation) — a moving spinner
72/// plus a message, with no byte/percent/ETA fields (there is nothing to
73/// count). Mirrors the cyan spinner the old build `Spinner` used.
74const STEP_TEMPLATE: &str = "{spinner:.cyan} {msg}";
75
76/// Determinate clone template: spinner, label/phase, bar, count,
77/// percent, ETA. Driven by gix's bounded object/file counts; the ETA
78/// is derived by indicatif from the count rate.
79const CLONE_TEMPLATE: &str =
80    "{spinner:.green} {msg} [{wide_bar:.cyan/blue}] {pos}/{len} ({percent}%, {eta})";
81
82/// Spinner template for the brief negotiation window before any
83/// bounded gix task exists (no total ⇒ nothing to estimate).
84const CLONE_TEMPLATE_SPINNER: &str = "{spinner:.green} {msg}";
85
86/// Progress glyphs for the filled bar (filled, current, remaining).
87const PROGRESS_CHARS: &str = "##-";
88
89/// Shared owner of the concurrent fetch progress bars for one resolve
90/// operation.
91///
92/// Wraps an [`indicatif::MultiProgress`] (constructed against a hidden
93/// draw target off-TTY) and hands out child bars via `download_bar` /
94/// `clone_progress`. Created once per resolve and shared across the rayon
95/// `resolve_specs_parallel` workers by `&` reference — `MultiProgress`
96/// is `Send + Sync` and `add` is internally serialized on an
97/// `Arc<RwLock>`, so concurrent `download_bar`/`clone_progress` calls
98/// are safe.
99///
100/// Off-TTY (`!stderr_color()`) the group draws to a hidden target:
101/// every child bar is a no-op, `inc`/`finish` emit nothing, and the
102/// gix poll thread is never spawned — piped/CI stderr stays
103/// escape-free, matching the [`crate::cli::Spinner`] degradation
104/// contract.
105pub struct FetchProgress {
106    /// Hidden draw target off-TTY; stderr otherwise. Always present
107    /// (vs `Option`) so child-bar construction is unconditional and
108    /// the hidden/visible branch lives in one place: the draw target.
109    multi: MultiProgress,
110}
111
112impl FetchProgress {
113    /// Construct a fetch-progress group. On a TTY it renders to
114    /// stderr; otherwise (non-TTY / `NO_COLOR` / `TERM=dumb`) it uses
115    /// a hidden draw target so nothing reaches piped output.
116    ///
117    /// The `stderr_color()` gate mirrors [`crate::cli::Spinner`];
118    /// indicatif additionally auto-hides on a non-TTY, so the hidden
119    /// behavior is correct even if the two ever disagreed.
120    pub fn new() -> Self {
121        let multi = if stderr_color() {
122            MultiProgress::new()
123        } else {
124            MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
125        };
126        Self { multi }
127    }
128
129    /// Whether the group draws to a hidden target (non-TTY / forced).
130    /// When hidden, [`Self::clone_progress`] skips its poll thread.
131    pub(crate) fn is_hidden(&self) -> bool {
132        self.multi.is_hidden()
133    }
134
135    /// Add a determinate byte-download bar. `total` is the response
136    /// `Content-Length`: `Some` ⇒ percent + ETA; `None` ⇒ a live byte
137    /// counter (rate, no ETA). `label` names the kernel being fetched
138    /// so concurrent bars are distinguishable. Off-TTY the returned
139    /// bar is hidden.
140    pub(crate) fn download_bar(&self, label: &str, total: Option<u64>) -> GroupBar {
141        let pb = match total {
142            Some(len) => ProgressBar::new(len),
143            None => ProgressBar::new_spinner(),
144        };
145        let pb = self.multi.add(pb);
146        let template = if total.is_some() {
147            DOWNLOAD_TEMPLATE
148        } else {
149            DOWNLOAD_TEMPLATE_NO_TOTAL
150        };
151        pb.set_style(
152            ProgressStyle::with_template(template)
153                .expect("valid download template")
154                .progress_chars(PROGRESS_CHARS),
155        );
156        pb.set_message(label.to_string());
157        // Skip the steady-tick ticker thread on the hidden path — it
158        // would redraw nothing yet still spawn a thread per bar (the
159        // exact CI/non-TTY case this module no-ops).
160        if !self.is_hidden() {
161            pb.enable_steady_tick(TICK);
162        }
163        GroupBar { pb }
164    }
165
166    /// Add a label-only spinner bar for a non-quantifiable build phase
167    /// (kernel configure / build / `compile_commands.json` generation).
168    /// Unlike [`Self::download_bar`] with `total = None`, it carries no
169    /// byte counter — the build phase has no bytes to count, only a
170    /// moving spinner and a message. Off-TTY it is hidden and the
171    /// steady-tick thread is skipped, matching [`Self::download_bar`].
172    ///
173    /// Routing the build phase through the group (rather than a
174    /// standalone [`crate::cli::Spinner`]) is what lets concurrent
175    /// builds in the parallel resolve render together without racing the
176    /// process-global `SPINNER_ACTIVE` guard.
177    pub(crate) fn step_bar(&self, label: &str) -> GroupBar {
178        let pb = self.multi.add(ProgressBar::new_spinner());
179        pb.set_style(ProgressStyle::with_template(STEP_TEMPLATE).expect("valid step template"));
180        pb.set_message(label.to_string());
181        if !self.is_hidden() {
182            pb.enable_steady_tick(TICK);
183        }
184        GroupBar { pb }
185    }
186
187    /// Add a git-clone progress bar and spawn the gix-tree → indicatif
188    /// poll bridge. `label` names the clone (its git ref). The bar is
189    /// determinate (with ETA) whenever gix reports a bounded task and
190    /// a spinner otherwise; see [`CloneProgress`].
191    ///
192    /// Off-TTY (`is_hidden()`), no poll thread is spawned — the
193    /// returned [`CloneProgress`] still yields a valid (no-op) gix
194    /// progress sink via [`CloneProgress::item`].
195    pub(crate) fn clone_progress(&self, label: &str) -> CloneProgress {
196        let root = Root::new();
197        let bar = self.multi.add(ProgressBar::new_spinner());
198        bar.set_style(
199            ProgressStyle::with_template(CLONE_TEMPLATE_SPINNER)
200                .expect("valid clone spinner template"),
201        );
202        bar.set_message(format!("cloning {label}"));
203
204        // Hidden: no rendering, so skip both the steady-tick ticker
205        // thread and the poll thread. The tree (root) still exists so
206        // `item()` hands gix a valid NestedProgress sink that simply
207        // goes unread.
208        if self.is_hidden() {
209            return CloneProgress {
210                root,
211                bar,
212                stop: Arc::new(AtomicBool::new(false)),
213                poller: None,
214            };
215        }
216
217        bar.enable_steady_tick(TICK);
218        let stop = Arc::new(AtomicBool::new(false));
219        let poller = std::thread::spawn({
220            let root = Arc::clone(&root);
221            let bar = bar.clone();
222            let stop = Arc::clone(&stop);
223            let label = label.to_string();
224            move || poll_clone_tree(root, bar, stop, label)
225        });
226        CloneProgress {
227            root,
228            bar,
229            stop,
230            poller: Some(poller),
231        }
232    }
233
234    /// Print a status line that coordinates with the live bars.
235    ///
236    /// On a visible group this routes through
237    /// [`indicatif::MultiProgress::println`] so the line lands above
238    /// the bars without garbling them (a raw `eprintln!` while bars
239    /// are drawing corrupts their cursor accounting). On a hidden
240    /// group (non-TTY / CI) `MultiProgress::println` is a no-op draw
241    /// that would *swallow* the line, so it falls back to `eprintln!`
242    /// to preserve the status output piped/CI consumers rely on.
243    ///
244    /// Best-effort: a `println` error (e.g. broken pipe) is discarded
245    /// — a status line must never abort a fetch.
246    pub(crate) fn println(&self, line: &str) {
247        if self.is_hidden() {
248            eprintln!("{line}");
249        } else {
250            let _ = self.multi.println(line);
251        }
252    }
253
254    /// Remove all bars from the group. Best-effort: a clear failure is
255    /// discarded so it can never mask the resolve result the caller is
256    /// returning.
257    pub fn clear(&self) {
258        let _ = self.multi.clear();
259    }
260}
261
262impl Default for FetchProgress {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268/// Poll the gix prodash tree at [`TICK`] cadence and mirror the most
269/// relevant task onto `bar` until `stop` is set.
270///
271/// Picks the deepest task that has a *bounded* counter (`done_at =
272/// Some` — a known total) so the bar shows a real position/length and
273/// indicatif can compute an ETA. Resolving deltas and checkout files
274/// are always bounded; receiving/read-pack is bounded only when the
275/// server advertises a pack size. Falls back to the deepest live task's
276/// name (spinner only) when no bounded task exists yet (negotiation, or
277/// an unadvertised pack size). The bar style is switched only on a
278/// determinate/indeterminate transition, not every tick.
279fn poll_clone_tree(root: Arc<Root>, bar: ProgressBar, stop: Arc<AtomicBool>, label: String) {
280    let mut snapshot: Vec<(Key, Task)> = Vec::new();
281    let mut determinate = false;
282    while !stop.load(Ordering::Relaxed) {
283        poll_tick(&root, &bar, &mut determinate, &label, &mut snapshot);
284        std::thread::sleep(TICK);
285    }
286}
287
288/// One poll iteration: snapshot the tree and mirror the most relevant
289/// task onto `bar`. Extracted from [`poll_clone_tree`] so the
290/// snapshot→bar mapping is unit-testable without spawning a thread or
291/// running a real clone. `determinate` carries the determinate/spinner
292/// style state across calls so the style is switched only on a
293/// transition, not every tick. `snapshot` is a caller-owned scratch
294/// buffer reused across ticks to avoid a per-tick allocation.
295fn poll_tick(
296    root: &Root,
297    bar: &ProgressBar,
298    determinate: &mut bool,
299    label: &str,
300    snapshot: &mut Vec<(Key, Task)>,
301) {
302    root.sorted_snapshot(snapshot);
303
304    // Deepest bounded task (known total) if any; else deepest task with
305    // any live counter. `sorted_snapshot` orders by Key lexicographically
306    // (pre-order DFS over the tree); the clone is a single parent chain
307    // at any instant (root → "clone" → gix's active child), so the last
308    // matching entry is the deepest active task.
309    let bounded = snapshot
310        .iter()
311        .rev()
312        .find(|(_, t)| t.progress.as_ref().and_then(|v| v.done_at).is_some());
313    let any = snapshot.iter().rev().find(|(_, t)| t.progress.is_some());
314
315    if let Some((_, task)) = bounded {
316        // Safe: `bounded` only matched tasks whose progress is Some.
317        if let Some(value) = task.progress.as_ref() {
318            if !*determinate {
319                bar.set_style(
320                    ProgressStyle::with_template(CLONE_TEMPLATE)
321                        .expect("valid clone template")
322                        .progress_chars(PROGRESS_CHARS),
323                );
324                *determinate = true;
325            }
326            bar.set_length(value.done_at.unwrap_or(0) as u64);
327            bar.set_position(value.step.load(Ordering::SeqCst) as u64);
328            bar.set_message(format!("{label}: {}", task.name));
329        }
330    } else {
331        // No bounded task: spinner mode. This covers negotiation, an
332        // unadvertised pack size, AND a no-counter task (prodash gives
333        // `progress = None` when init is called with neither a max nor a
334        // unit, e.g. gix's "negotiate") — in all of which `any` may be
335        // None. Flip the style back on the determinate→spinner
336        // transition regardless, so the bar never stays stuck showing a
337        // stale determinate length; surface the deepest live task's name
338        // when one exists.
339        if *determinate {
340            bar.set_style(
341                ProgressStyle::with_template(CLONE_TEMPLATE_SPINNER)
342                    .expect("valid clone spinner template"),
343            );
344            *determinate = false;
345        }
346        if let Some((_, task)) = any {
347            bar.set_message(format!("{label}: {}", task.name));
348        }
349    }
350}
351
352/// A single bar within a [`FetchProgress`] group.
353///
354/// Backs two uses. A determinate (or byte-counter) download bar
355/// ([`FetchProgress::download_bar`]): its inner
356/// [`indicatif::ProgressBar`] is handed to
357/// `crate::fetch::DownloadStream::with_progress` via [`Self::bar`] and
358/// advanced with `inc(n)` beside the stream's own byte accounting — a
359/// single source of truth, so `position()` always equals the streamed
360/// `bytes_total`. And a label-only build-phase spinner
361/// ([`FetchProgress::step_bar`]) that only ever calls [`Self::finish`]
362/// (its [`Self::bar`] is unused — there is no stream to attach). Off-TTY
363/// the bar is hidden and every method is a no-op draw.
364pub(crate) struct GroupBar {
365    pb: ProgressBar,
366}
367
368impl GroupBar {
369    /// A clone of the inner bar to attach to the download stream
370    /// (download-bar use only). `ProgressBar` is `Arc`-backed, so the
371    /// clone drives the same bar.
372    pub(crate) fn bar(&self) -> ProgressBar {
373        self.pb.clone()
374    }
375
376    /// Finish and clear the bar. Idempotent.
377    pub(crate) fn finish(&self) {
378        self.pb.finish_and_clear();
379    }
380}
381
382impl Drop for GroupBar {
383    /// Clear the bar if it was not explicitly finished — covers the
384    /// download fns' extraction-error exit (bar already created, the
385    /// xz/gzip unpack fails via `?` before [`Self::finish`]) that
386    /// bails with a live, unfinished bar. HTTP failure and HTML reject
387    /// bail before the bar is constructed; a sha256 mismatch bails
388    /// after `finish()` has already run, so Drop is only a redundant
389    /// no-op there.
390    /// Idempotent with `finish`: a second `finish_and_clear` is a
391    /// no-op on an already-cleared bar.
392    fn drop(&mut self) {
393        self.pb.finish_and_clear();
394    }
395}
396
397/// Live git-clone progress bridge.
398///
399/// Holds the prodash [`tree::Root`](Root), the indicatif bar, and (on
400/// a TTY) the background poll thread spawned by
401/// [`FetchProgress::clone_progress`]. [`Self::item`] yields a fresh gix
402/// progress sink ([`tree::Item`](Item)) for each gix call; both the
403/// fetch and checkout phases feed the one polled tree.
404///
405/// Shutdown is leak-proof: [`Self::finish`] (success path) and the
406/// [`Drop`] impl (a `git_clone` that bails via `?`) both signal the
407/// poll thread to stop and join it, then clear the bar — never detached,
408/// never leaked. Under the release profile's `panic = "abort"`, `Drop`
409/// does not run on a panic, but the whole process aborts so the poll
410/// thread cannot outlive it either.
411pub(crate) struct CloneProgress {
412    /// The prodash progress tree gix writes into. Shared (`Arc`) with
413    /// the poll thread, which snapshots it.
414    root: Arc<Root>,
415    /// The indicatif bar the poll thread drives. Cleared on shutdown.
416    bar: ProgressBar,
417    /// Set by [`Self::shutdown`] to stop the poll loop.
418    stop: Arc<AtomicBool>,
419    /// The poll thread handle. `None` off-TTY (no thread spawned) and
420    /// after [`Self::shutdown`] takes it to join.
421    poller: Option<JoinHandle<()>>,
422}
423
424impl CloneProgress {
425    /// A fresh gix progress sink for one gix call. `git_clone` calls
426    /// this once for the fetch phase and once for checkout; both
427    /// children feed the single polled tree, so the one bar reflects
428    /// whichever phase is active.
429    pub(crate) fn item(&self) -> Item {
430        self.root.add_child("clone")
431    }
432
433    /// Stop the poll thread, join it, and clear the bar. Consuming
434    /// form for the success path; [`Drop`] performs the same work on
435    /// the error/panic path.
436    pub(crate) fn finish(mut self) {
437        self.shutdown();
438    }
439
440    /// Idempotent teardown: signal stop, join the poll thread (if any
441    /// — `Option::take` makes a second call a no-op), then clear the
442    /// bar. The join happens after both gix calls have returned, so
443    /// the poll thread is never holding the snapshot lock across it.
444    fn shutdown(&mut self) {
445        self.stop.store(true, Ordering::Relaxed);
446        if let Some(handle) = self.poller.take() {
447            let _ = handle.join();
448        }
449        self.bar.finish_and_clear();
450    }
451}
452
453impl Drop for CloneProgress {
454    fn drop(&mut self) {
455        self.shutdown();
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    /// A force-hidden group, independent of whether the test host has a
464    /// TTY. Constructs `FetchProgress` directly (white-box: the tests
465    /// are a child module of `progress`) so no production-only `hidden`
466    /// constructor is needed.
467    fn hidden_group() -> FetchProgress {
468        FetchProgress {
469            multi: MultiProgress::with_draw_target(ProgressDrawTarget::hidden()),
470        }
471    }
472
473    /// Under nextest, stderr is captured (not a TTY), so the default
474    /// group degrades to a hidden draw target — the arm CI runs.
475    #[test]
476    fn fetch_progress_new_is_hidden_under_non_tty() {
477        assert!(
478            FetchProgress::new().is_hidden(),
479            "a non-TTY FetchProgress must use a hidden draw target so piped/CI stderr stays clean",
480        );
481    }
482
483    /// A determinate download bar tracks `inc` exactly and exposes the
484    /// configured total — the contract `DownloadStream` relies on
485    /// (position advances by the bytes it streams).
486    #[test]
487    fn download_bar_determinate_tracks_inc() {
488        let fp = hidden_group();
489        let bar = fp.download_bar("6.14.2", Some(100));
490        bar.pb.inc(40);
491        bar.pb.inc(60);
492        assert_eq!(bar.pb.position(), 100);
493        assert_eq!(bar.pb.length(), Some(100));
494    }
495
496    /// With no Content-Length the bar is indeterminate (no total) but
497    /// still counts bytes — a live counter rather than a percent bar.
498    #[test]
499    fn download_bar_indeterminate_when_no_total() {
500        let fp = hidden_group();
501        let bar = fp.download_bar("6.15-rc3", None);
502        assert_eq!(bar.pb.length(), None);
503        bar.pb.inc(10);
504        assert_eq!(bar.pb.position(), 10);
505    }
506
507    /// `finish` is idempotent and the `Drop` impl also clears — neither
508    /// the explicit-finish nor the drop-without-finish path panics.
509    #[test]
510    fn download_bar_finish_and_drop_no_panic() {
511        let fp = hidden_group();
512        let bar = fp.download_bar("k", Some(10));
513        bar.finish();
514        bar.finish();
515        drop(bar);
516        // A bar dropped without an explicit finish (error path) clears
517        // via Drop without panic.
518        let bar2 = fp.download_bar("k2", Some(10));
519        bar2.pb.inc(5);
520        drop(bar2);
521    }
522
523    /// Adding and driving many bars concurrently from worker threads
524    /// (the rayon `resolve_specs_parallel` shape) must not panic or
525    /// deadlock — `MultiProgress` serializes `add` on its `RwLock`,
526    /// and `FetchProgress` is `Sync` so `&fp` shares across threads.
527    #[test]
528    fn fetch_progress_concurrent_bars_no_panic() {
529        let fp = hidden_group();
530        std::thread::scope(|s| {
531            for i in 0..16u32 {
532                let fp = &fp;
533                s.spawn(move || {
534                    let bar = fp.download_bar(&format!("k{i}"), Some(50));
535                    for _ in 0..50 {
536                        bar.pb.inc(1);
537                    }
538                    assert_eq!(bar.pb.position(), 50, "each bar accounts independently");
539                    bar.finish();
540                });
541            }
542        });
543        fp.clear();
544    }
545
546    /// A hidden group skips the poll thread entirely (nothing to
547    /// render), yet still yields a valid gix progress sink via
548    /// `item()`, and `finish` is clean.
549    #[test]
550    fn clone_progress_hidden_skips_poller() {
551        let fp = hidden_group();
552        let cp = fp.clone_progress("for-next");
553        assert!(
554            cp.poller.is_none(),
555            "hidden group must not spawn the poll thread",
556        );
557        let _item = cp.item();
558        cp.finish();
559    }
560
561    /// The poll thread shuts down and joins cleanly. Constructs a
562    /// `CloneProgress` over a real (unrendered) tree with the poller
563    /// running, then `finish` must stop + join within one tick without
564    /// hanging — pinning the leak-proof shutdown path.
565    #[test]
566    fn clone_progress_finish_joins_poll_thread() {
567        let root = Root::new();
568        let bar = ProgressBar::hidden();
569        let stop = Arc::new(AtomicBool::new(false));
570        let poller = std::thread::spawn({
571            let root = Arc::clone(&root);
572            let bar = bar.clone();
573            let stop = Arc::clone(&stop);
574            move || poll_clone_tree(root, bar, stop, "for-next".to_string())
575        });
576        let cp = CloneProgress {
577            root,
578            bar,
579            stop,
580            poller: Some(poller),
581        };
582        // Must not hang: sets stop, the loop exits within one TICK, join
583        // returns.
584        cp.finish();
585    }
586
587    /// A bounded gix task (known total) maps onto a determinate bar:
588    /// length = `done_at`, position = `step`. Deterministic — drives
589    /// the tree and runs one tick, no thread, no sleep.
590    #[test]
591    fn poll_tick_maps_bounded_task_to_determinate_bar() {
592        let root = Root::new();
593        let bar = ProgressBar::hidden();
594        let child = root.add_child("receiving objects");
595        child.init(Some(200), None);
596        child.set(80);
597
598        let mut determinate = false;
599        let mut snapshot = Vec::new();
600        poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
601
602        assert!(
603            determinate,
604            "a bounded task must switch the bar to determinate"
605        );
606        assert_eq!(bar.length(), Some(200));
607        assert_eq!(bar.position(), 80);
608    }
609
610    /// An unbounded gix task (no total — e.g. the negotiation phase)
611    /// leaves the bar in spinner mode: `determinate` stays false and no
612    /// length is set.
613    #[test]
614    fn poll_tick_unbounded_task_stays_spinner() {
615        let root = Root::new();
616        let bar = ProgressBar::hidden();
617        let child = root.add_child("negotiate");
618        child.init(None, None);
619        child.set(3);
620
621        let mut determinate = false;
622        let mut snapshot = Vec::new();
623        poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
624
625        assert!(
626            !determinate,
627            "an unbounded task must not flip the bar to determinate",
628        );
629        assert_eq!(bar.length(), None, "no total ⇒ no determinate length");
630    }
631
632    /// Once a bounded task has flipped the bar to determinate, a later
633    /// snapshot whose only live task is unbounded flips it back to
634    /// spinner mode — the bounded→unbounded transition branch in
635    /// `poll_tick` (gix can drop a bounded child before the next bounded
636    /// phase appears).
637    #[test]
638    fn poll_tick_flips_back_to_spinner_when_unbounded() {
639        let root = Root::new();
640        let bar = ProgressBar::hidden();
641        let mut snapshot = Vec::new();
642        let mut determinate = false;
643
644        let bounded = root.add_child("resolving deltas");
645        bounded.init(Some(50), None);
646        bounded.set(10);
647        poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
648        assert!(determinate, "a bounded task must flip to determinate first");
649
650        // Drop the bounded task (gix removes a finished child from the
651        // tree) and leave only an unbounded one.
652        drop(bounded);
653        let unbounded = root.add_child("negotiate");
654        unbounded.init(None, None);
655        unbounded.set(1);
656        poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
657        assert!(
658            !determinate,
659            "an unbounded-only snapshot must flip the bar back to spinner mode",
660        );
661    }
662
663    /// `println` on a hidden group selects the `eprintln!` arm (gated by
664    /// `is_hidden()`) rather than `multi.println` — which would swallow
665    /// the line on a hidden draw target — and does not panic. Pins
666    /// branch selection + no-panic; like the sibling `Spinner` tests it
667    /// does not capture stderr to assert the bytes are emitted (that is
668    /// std `eprintln!` behavior). The anti-swallow rationale is
669    /// documented on [`FetchProgress::println`] itself.
670    #[test]
671    fn fetch_progress_println_hidden_no_panic() {
672        let fp = hidden_group();
673        // is_hidden() == true selects the eprintln! arm inside println.
674        assert!(fp.is_hidden());
675        fp.println("cargo ktstr: downloading linux-6.14.2 (130.0 MiB)");
676    }
677
678    /// Several real poll threads created and shut down concurrently from
679    /// worker threads (the rayon multi-`git+URL` fan-out shape) must
680    /// neither panic nor hang — pins the concurrent clone-shutdown path
681    /// that the single-threaded `clone_progress_finish_joins_poll_thread`
682    /// test does not reach.
683    #[test]
684    fn concurrent_clone_progress_shutdown_no_panic() {
685        std::thread::scope(|s| {
686            for _ in 0..8 {
687                s.spawn(|| {
688                    let root = Root::new();
689                    let bar = ProgressBar::hidden();
690                    let stop = Arc::new(AtomicBool::new(false));
691                    let poller = std::thread::spawn({
692                        let root = Arc::clone(&root);
693                        let bar = bar.clone();
694                        let stop = Arc::clone(&stop);
695                        move || poll_clone_tree(root, bar, stop, "ref".to_string())
696                    });
697                    let cp = CloneProgress {
698                        root,
699                        bar,
700                        stop,
701                        poller: Some(poller),
702                    };
703                    let _ = cp.item();
704                    cp.finish();
705                });
706            }
707        });
708    }
709
710    /// A `step_bar` is a label-only spinner: no byte counter (length is
711    /// `None`), and finish + drop-without-finish are both clean.
712    #[test]
713    fn step_bar_no_counter_no_panic() {
714        let fp = hidden_group();
715        let bar = fp.step_bar("Building kernel...");
716        assert_eq!(
717            bar.pb.length(),
718            None,
719            "a step bar has no quantifiable total"
720        );
721        bar.finish();
722        // Drop-without-finish (the build error path) must also be clean.
723        drop(fp.step_bar("Configuring kernel..."));
724    }
725
726    /// Many build-phase `step_bar`s driven concurrently on one group
727    /// (the parallel-resolve shape where multiple workers build at once)
728    /// must not panic. Direct regression pin for the old
729    /// concurrent-`Spinner` `SPINNER_ACTIVE` race that this change
730    /// removes by routing the build phase through the group instead of a
731    /// standalone `Spinner`.
732    #[test]
733    fn concurrent_step_bars_no_panic() {
734        let fp = hidden_group();
735        std::thread::scope(|s| {
736            for i in 0..16u32 {
737                let fp = &fp;
738                s.spawn(move || {
739                    let bar = fp.step_bar(&format!("Building kernel {i}..."));
740                    bar.finish();
741                });
742            }
743        });
744        fp.clear();
745    }
746}