Skip to main content

mkit_cli/
progress.rs

1//! Honest transfer-progress reporting for `clone`/`push`/`pull`/`fetch`
2//! (#711).
3//!
4//! `mkit clone`/`push`/`pull`/`fetch` previously printed only a start
5//! banner and a final summary — the network transfer itself was silent.
6//! This module adds a lightweight, thread-local progress sink that the
7//! transfer call chain (`push_branch_with_depth` in
8//! `remote_dispatch::mod`, `unpack_downloaded_packs` in
9//! `remote_dispatch::packmap`) reports real, already-happened work to:
10//! objects staged into the outgoing pack, bytes handed to the transport,
11//! and objects unpacked from a downloaded pack.
12//!
13//! It deliberately never reports git's fabricated
14//! `Enumerating/Counting/Compressing objects` or `Total N (delta D)`
15//! lines — mkit's transport is one-object-per-pack and computes no
16//! cross-branch delta graph, so those numbers would be invented (see
17//! `docs/PARITY.md`'s "Human-facing output parity" section).
18//!
19//! ## Threading pattern
20//!
21//! Rather than adding a progress parameter to every function in the
22//! `push_all_with` → `push_branch_with_depth` → `pull_all` →
23//! `fetch_objects` call chain (touching dozens of existing call sites,
24//! including many integration tests that don't care about progress at
25//! all), this mirrors the pattern already used for interrupt handling:
26//! `crate::signal::is_shutdown()` is a global checkpoint polled inside
27//! the same loops. Here, [`report`] is the equivalent checkpoint —  a
28//! thread-local sink installed by [`start`] and torn down by the
29//! returned [`Guard`]'s `Drop`. When no sink is installed (the common
30//! case: every test that doesn't call [`start`], and any non-interactive
31//! run), `report` is a cheap thread-local check that does nothing.
32//! Concurrent callers (see `fetch_pull_lock_scope.rs`, which fetches
33//! from multiple threads) are unaffected: the sink is thread-local, so
34//! each thread has its own (absent, by default) reporter.
35//!
36//! ## Interactivity gating
37//!
38//! Mirrors `term::use_color_stderr`'s tty auto-detection: progress is
39//! shown only when stderr is a tty, unless overridden by an explicit
40//! `--quiet` flag (forces off) or the `MKIT_PROGRESS` env var
41//! (`always`/`never`/`auto`, mirroring `NO_COLOR`/`CLICOLOR_FORCE`'s
42//! override convention) — `always` is how the CLI integration tests
43//! observe progress lines over a piped (non-tty) stderr.
44
45use std::cell::RefCell;
46use std::io::{IsTerminal, Write};
47
48/// One real, already-happened unit of transfer work. Never a projection
49/// or estimate.
50#[derive(Debug, Clone, Copy)]
51pub enum Event {
52    /// `count` objects were appended to the outgoing pack(s) (push side,
53    /// `build_and_upload_packs`'s `plan.raw` / `plan.deltas` loops).
54    ObjectsPacked(usize),
55    /// A finished pack (`bytes` long) was handed to
56    /// `Transport::upload_pack` — that pack's upload is complete. Fires
57    /// once per pack; a push whose plan exceeds a single pack's payload
58    /// cap fires this more than once, and `bytes` accumulates across
59    /// calls (issue #831) rather than reporting only the last pack.
60    PackUploaded(u64),
61    /// `count` objects were unpacked from one downloaded pack (pull/fetch
62    /// side, `unpack_downloaded_packs`) — real counts from the pack's own
63    /// [`mkit_core::pack::UnpackReport`].
64    ObjectsUnpacked(usize),
65}
66
67/// Objects between throttled stderr re-writes. The final event
68/// ([`Event::PackUploaded`], and [`Guard`]'s `Drop`) always emits
69/// regardless of this threshold, so the last line reflects the true
70/// final count even when it doesn't land on an interval boundary.
71const REPORT_INTERVAL: usize = 8;
72
73struct Reporter {
74    label: &'static str,
75    total: Option<usize>,
76    done: usize,
77    bytes: u64,
78    last_emit_done: usize,
79    emitted: bool,
80}
81
82impl Reporter {
83    fn new(label: &'static str, total: Option<usize>) -> Self {
84        Self {
85            label,
86            total,
87            done: 0,
88            bytes: 0,
89            last_emit_done: 0,
90            emitted: false,
91        }
92    }
93
94    fn record(&mut self, event: Event) {
95        match event {
96            Event::ObjectsPacked(n) | Event::ObjectsUnpacked(n) => {
97                self.done += n;
98                if self.done.saturating_sub(self.last_emit_done) >= REPORT_INTERVAL {
99                    self.emit();
100                }
101            }
102            Event::PackUploaded(bytes) => {
103                // Accumulate, not overwrite: a multi-pack push (#831)
104                // fires this once per pack, and the reported total must
105                // cover every pack uploaded so far, not just the last one.
106                self.bytes = self.bytes.saturating_add(bytes);
107                self.emit();
108            }
109        }
110    }
111
112    fn emit(&mut self) {
113        self.last_emit_done = self.done;
114        self.emitted = true;
115        let mut stderr = std::io::stderr().lock();
116        let _ = match (self.total, self.bytes) {
117            (Some(total), 0) => write!(stderr, "\r{}: {}/{} objects", self.label, self.done, total),
118            (Some(total), bytes) => write!(
119                stderr,
120                "\r{}: {}/{} objects, {} bytes",
121                self.label, self.done, total, bytes
122            ),
123            (None, 0) => write!(stderr, "\r{}: {} objects", self.label, self.done),
124            (None, bytes) => write!(
125                stderr,
126                "\r{}: {} objects, {} bytes",
127                self.label, self.done, bytes
128            ),
129        };
130        let _ = stderr.flush();
131    }
132
133    /// Force a final emit (bypassing the throttle) and move past the
134    /// self-overwriting `\r` line so later output isn't clobbered by it.
135    /// A no-op when nothing was ever reported (e.g. a no-op push).
136    fn finish(&mut self) {
137        if self.done == 0 && self.bytes == 0 {
138            return;
139        }
140        self.emit();
141        let mut stderr = std::io::stderr().lock();
142        let _ = writeln!(stderr, ", done.");
143    }
144}
145
146thread_local! {
147    static REPORTER: RefCell<Option<Reporter>> = const { RefCell::new(None) };
148}
149
150/// RAII handle returned by [`start`]. Dropping it flushes a final
151/// progress line (if anything was reported) and uninstalls the
152/// thread-local sink, so a command can simply hold the guard for the
153/// duration of its transfer call and let scope-exit (including an early
154/// `return` on error) clean up.
155#[derive(Debug)]
156#[must_use = "dropping this immediately ends progress reporting"]
157pub struct Guard {
158    _private: (),
159}
160
161impl Drop for Guard {
162    fn drop(&mut self) {
163        REPORTER.with(|r| {
164            if let Some(mut rep) = r.borrow_mut().take() {
165                rep.finish();
166            }
167        });
168    }
169}
170
171/// Install a thread-local progress reporter for the duration of the
172/// returned [`Guard`]. `enabled = false` installs no reporter, so
173/// [`report`] stays a cheap no-op — used when stderr isn't interactive
174/// or `--quiet` was passed (see [`should_report`]).
175///
176/// `total`, when known ahead of time (the push side plans its pack
177/// before building it), renders as `done/total`; `None` (the fetch/pull
178/// side, where the object count isn't known until each pack is
179/// downloaded) renders as a running count only — never a fabricated
180/// total.
181pub fn start(label: &'static str, total: Option<usize>, enabled: bool) -> Guard {
182    REPORTER.with(|r| {
183        *r.borrow_mut() = if enabled {
184            Some(Reporter::new(label, total))
185        } else {
186            None
187        };
188    });
189    Guard { _private: () }
190}
191
192/// Report one real unit of already-completed transfer work to the
193/// current thread's installed reporter, if any. A no-op — a single
194/// thread-local check — when no [`Guard`] is active on this thread,
195/// which is the default for every caller that doesn't opt in (including
196/// every existing test that drives `push_branch_with_depth` /
197/// `push_all` / `pull_all` / `fetch_all` directly).
198pub fn report(event: Event) {
199    REPORTER.with(|r| {
200        // `try_borrow_mut` rather than `borrow_mut`: `report` is called
201        // from deep inside the transfer call chain and must never panic
202        // on a re-entrant borrow; silently dropping a progress tick is
203        // harmless (the running total is cosmetic), unlike the transfer
204        // itself.
205        if let Ok(mut slot) = r.try_borrow_mut()
206            && let Some(rep) = slot.as_mut()
207        {
208            rep.record(event);
209        }
210    });
211}
212
213/// Whether progress should be shown on stderr: not explicitly silenced
214/// (`--quiet` / `-q`), and either `MKIT_PROGRESS` forces a decision or
215/// stderr is a tty. Mirrors `term::use_color_stderr`'s
216/// `NO_COLOR`/`CLICOLOR_FORCE`-style override convention — `always` is
217/// how CLI integration tests observe progress lines over a piped
218/// (non-tty) stderr; `never` is an explicit opt-out distinct from
219/// `--quiet` (e.g. for scripting environments that set it once instead
220/// of threading `--quiet` through every call site).
221#[must_use]
222pub fn should_report(quiet: bool) -> bool {
223    if quiet {
224        return false;
225    }
226    match std::env::var("MKIT_PROGRESS").ok().as_deref() {
227        Some("always") => true,
228        Some("never") => false,
229        _ => std::io::stderr().is_terminal(),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// `report` with no active [`Guard`] must not panic and must not
238    /// touch stderr (there's no reporter to write through) — the
239    /// no-op path every existing push/pull/fetch integration test takes.
240    #[test]
241    fn report_without_guard_is_a_silent_no_op() {
242        report(Event::ObjectsPacked(1));
243        report(Event::PackUploaded(128));
244        report(Event::ObjectsUnpacked(3));
245    }
246
247    /// issue #831: a multi-pack push fires `PackUploaded` once per
248    /// pack. The reported byte total must accumulate across those
249    /// calls, not report only the last pack (the bug this test pins).
250    #[test]
251    fn pack_uploaded_accumulates_across_multiple_packs() {
252        let mut rep = Reporter::new("Writing objects", None);
253        rep.record(Event::PackUploaded(100));
254        assert_eq!(rep.bytes, 100);
255        rep.record(Event::PackUploaded(50));
256        assert_eq!(rep.bytes, 150, "second pack's bytes must add, not replace");
257        rep.record(Event::PackUploaded(25));
258        assert_eq!(rep.bytes, 175);
259    }
260
261    /// A disabled guard (`enabled: false`) installs no reporter, so
262    /// `report` inside its scope is still the no-op path.
263    #[test]
264    fn disabled_guard_installs_no_reporter() {
265        let guard = start("Writing objects", Some(4), false);
266        report(Event::ObjectsPacked(4));
267        drop(guard);
268    }
269
270    /// `should_report` precedence: `--quiet` wins outright, then
271    /// `MKIT_PROGRESS`, then tty-ness. Exercised via the pure
272    /// tty-independent branches only (quiet, and the env var forcing a
273    /// decision) — this process's stderr tty-ness varies by how tests
274    /// are invoked, so the `_ =>` fallthrough isn't asserted here.
275    #[test]
276    fn should_report_quiet_always_wins() {
277        assert!(!should_report(true));
278    }
279}