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}