Skip to main content

grit_lib/
test_tool_progress.rs

1//! `test-tool progress` — exercises Git-compatible progress display (`t0500`).
2//!
3//! Mirrors `git/t/helper/test-progress.c` and `git/progress.c` with `GIT_TEST_PROGRESS_ONLY`
4//! behavior (no SIGALRM; fake elapsed time via `throughput` lines setting `progress_test_ns`).
5
6use std::io::{self, BufRead, Write};
7
8use unicode_width::UnicodeWidthStr;
9
10use crate::diffstat::terminal_columns;
11
12const TP_IDX_MAX: usize = 8;
13
14thread_local! {
15    static PROGRESS_TEST_NS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
16}
17
18fn utf8_display_width(s: &str) -> usize {
19    UnicodeWidthStr::width(s)
20}
21
22/// Match `humanise_bytes()` in `git/strbuf.c` (non-rate).
23fn humanise_bytes_value_unit(bytes: u64, rate: bool) -> (String, &'static str) {
24    if bytes > 1 << 30 {
25        let value = format!(
26            "{}.{:02}",
27            bytes >> 30,
28            (bytes & ((1 << 30) - 1)) / 10_737_419
29        );
30        let unit = if rate { "GiB/s" } else { "GiB" };
31        (value, unit)
32    } else if bytes > 1 << 20 {
33        let x = bytes + 5243;
34        let value = format!("{}.{:02}", x >> 20, ((x & ((1 << 20) - 1)) * 100) >> 20);
35        let unit = if rate { "MiB/s" } else { "MiB" };
36        (value, unit)
37    } else if bytes > 1 << 10 {
38        let x = bytes + 5;
39        let value = format!("{}.{:02}", x >> 10, ((x & ((1 << 10) - 1)) * 100) >> 10);
40        let unit = if rate { "KiB/s" } else { "KiB" };
41        (value, unit)
42    } else {
43        let value = format!("{bytes}");
44        let unit = if rate {
45            if bytes == 1 {
46                "byte/s"
47            } else {
48                "bytes/s"
49            }
50        } else if bytes == 1 {
51            "byte"
52        } else {
53            "bytes"
54        };
55        (value, unit)
56    }
57}
58
59fn throughput_display(total: u64, rate: u32) -> String {
60    let (v1, u1) = humanise_bytes_value_unit(total, false);
61    let (v2, u2) = humanise_bytes_value_unit(u64::from(rate).saturating_mul(1024), true);
62    format!(", {v1} {u1} | {v2} {u2}")
63}
64
65struct Throughput {
66    curr_total: u64,
67    prev_total: u64,
68    prev_ns: u64,
69    avg_bytes: u64,
70    avg_misecs: u64,
71    last_bytes: [u32; TP_IDX_MAX],
72    last_misecs: [u32; TP_IDX_MAX],
73    idx: usize,
74    display: String,
75}
76
77impl Throughput {
78    fn new(byte_count: u64, now_ns: u64) -> Self {
79        Self {
80            curr_total: byte_count,
81            prev_total: byte_count,
82            prev_ns: now_ns,
83            avg_bytes: 0,
84            avg_misecs: 0,
85            last_bytes: [0; TP_IDX_MAX],
86            last_misecs: [0; TP_IDX_MAX],
87            idx: 0,
88            display: String::new(),
89        }
90    }
91}
92
93struct Progress {
94    title: String,
95    last_value: Option<u64>,
96    total: u64,
97    last_percent: Option<u32>,
98    throughput: Option<Throughput>,
99    start_ns: u64,
100    counters_sb: String,
101    title_len: usize,
102    split: bool,
103    force_update: bool,
104}
105
106impl Progress {
107    fn new(title: String, total: u64) -> Self {
108        let title_len = utf8_display_width(&title);
109        let start_ns = nanotime();
110        Self {
111            title,
112            last_value: None,
113            total,
114            last_percent: None,
115            throughput: None,
116            start_ns,
117            counters_sb: String::new(),
118            title_len,
119            split: false,
120            force_update: false,
121        }
122    }
123
124    fn now_ns(&self) -> u64 {
125        self.start_ns.saturating_add(PROGRESS_TEST_NS.get())
126    }
127
128    fn render_line(&mut self, n: u64, done_suffix: Option<&str>) -> io::Result<()> {
129        let mut show_update = false;
130        let update = self.force_update;
131        self.force_update = false;
132
133        let last_count_len = self.counters_sb.len();
134        self.last_value = Some(n);
135
136        let tp = self
137            .throughput
138            .as_ref()
139            .map(|t| t.display.as_str())
140            .unwrap_or("");
141
142        if self.total > 0 {
143            let percent: u32 = ((n as u128 * 100) / u128::from(self.total)) as u32;
144            if Some(percent) != self.last_percent || update {
145                self.last_percent = Some(percent);
146                self.counters_sb = format!("{:3}% ({n}/{}){tp}", percent, self.total);
147                show_update = true;
148            }
149        } else if update {
150            self.counters_sb = format!("{n}{tp}");
151            show_update = true;
152        }
153
154        if !show_update {
155            return Ok(());
156        }
157
158        let stderr = io::stderr();
159        let show = is_foreground_stderr(&stderr) || done_suffix.is_some();
160        if !show {
161            return Ok(());
162        }
163
164        let eol = done_suffix.unwrap_or("\r");
165        let clear_len = if self.counters_sb.len() < last_count_len {
166            last_count_len - self.counters_sb.len() + 1
167        } else {
168            0
169        };
170        let progress_line_len = self.title_len + self.counters_sb.len() + 2;
171        let cols = terminal_columns();
172
173        let mut err = stderr.lock();
174        if self.split {
175            // Git: `fprintf(stderr, "  %s%*s", counters_sb->buf, (int) clear_len, eol);`
176            let w = clear_len.max(eol.len());
177            write!(err, "  {}{:>w$}", self.counters_sb, eol, w = w)?;
178        } else if done_suffix.is_none() && cols < progress_line_len {
179            let title_pad = if self.title_len + 1 < cols {
180                cols - self.title_len - 1
181            } else {
182                0
183            };
184            write!(
185                err,
186                "{}:{}\n  {}{:>w$}",
187                self.title,
188                " ".repeat(title_pad),
189                self.counters_sb,
190                eol,
191                w = clear_len.max(eol.len())
192            )?;
193            self.split = true;
194        } else {
195            write!(
196                err,
197                "{}: {}{:>w$}",
198                self.title,
199                self.counters_sb,
200                eol,
201                w = clear_len.max(eol.len())
202            )?;
203        }
204        err.flush()?;
205        Ok(())
206    }
207
208    fn display_progress(&mut self, n: u64) -> io::Result<()> {
209        self.render_line(n, None)
210    }
211
212    fn display_throughput(&mut self, total: u64, global_update: bool) -> io::Result<()> {
213        let now_ns = self.now_ns();
214
215        if self.throughput.is_none() {
216            self.throughput = Some(Throughput::new(total, now_ns));
217            return Ok(());
218        }
219        let Some(tp) = self.throughput.as_mut() else {
220            return Ok(());
221        };
222        tp.curr_total = total;
223
224        if now_ns.saturating_sub(tp.prev_ns) <= 500_000_000 {
225            return Ok(());
226        }
227
228        let misecs: u32 = (((now_ns - tp.prev_ns) as u128 * 4398) >> 32) as u32;
229        let count = total.saturating_sub(tp.prev_total);
230        tp.prev_total = total;
231        tp.prev_ns = now_ns;
232        tp.avg_bytes = tp.avg_bytes.saturating_add(count);
233        tp.avg_misecs = tp.avg_misecs.saturating_add(u64::from(misecs));
234        let rate = if tp.avg_misecs > 0 {
235            (tp.avg_bytes / tp.avg_misecs) as u32
236        } else {
237            0
238        };
239        tp.avg_bytes = tp
240            .avg_bytes
241            .saturating_sub(u64::from(tp.last_bytes[tp.idx]));
242        tp.avg_misecs = tp
243            .avg_misecs
244            .saturating_sub(u64::from(tp.last_misecs[tp.idx]));
245        tp.last_bytes[tp.idx] = count as u32;
246        tp.last_misecs[tp.idx] = misecs;
247        tp.idx = (tp.idx + 1) % TP_IDX_MAX;
248
249        tp.display = throughput_display(total, rate);
250
251        if self.last_value.is_some() && global_update {
252            let n = self.last_value.unwrap_or(0);
253            self.force_update = true;
254            self.display_progress(n)?;
255        }
256        Ok(())
257    }
258
259    fn force_last_update(&mut self, msg: &str) -> io::Result<()> {
260        let now_ns = self.now_ns();
261        if let Some(tp) = self.throughput.as_mut() {
262            let misecs: u32 =
263                (((now_ns.saturating_sub(self.start_ns)) as u128 * 4398) >> 32) as u32;
264            let rate = if misecs > 0 {
265                (tp.curr_total / u64::from(misecs)) as u32
266            } else {
267                0
268            };
269            tp.display = throughput_display(tp.curr_total, rate);
270        }
271        self.force_update = true;
272        let n = self.last_value.unwrap_or(0);
273        let done = format!(", {msg}.\n");
274        self.render_line(n, Some(&done))?;
275        Ok(())
276    }
277
278    fn stop(&mut self, trace_path: Option<&str>) -> io::Result<()> {
279        if self.last_value.is_some() {
280            self.force_last_update("done")?;
281        }
282        if let Some(path) = trace_path {
283            trace2_append_json_line(
284                path,
285                &format!(
286                    r#"{{"event":"data","sid":"grit-0","time":"{}","category":"progress","key":"total_objects","value":"{}"}}"#,
287                    trace_now(),
288                    self.total
289                ),
290            )?;
291            if let Some(tp) = &self.throughput {
292                trace2_append_json_line(
293                    path,
294                    &format!(
295                        r#"{{"event":"data","sid":"grit-0","time":"{}","category":"progress","key":"total_bytes","value":"{}"}}"#,
296                        trace_now(),
297                        tp.curr_total
298                    ),
299                )?;
300            }
301            trace2_append_json_line(
302                path,
303                &format!(
304                    r#"{{"event":"region_leave","sid":"grit-0","time":"{}","category":"progress","label":"{}","t_rel":0.0}}"#,
305                    trace_now(),
306                    json_escape(&self.title)
307                ),
308            )?;
309        }
310        Ok(())
311    }
312}
313
314fn nanotime() -> u64 {
315    use std::time::{SystemTime, UNIX_EPOCH};
316    SystemTime::now()
317        .duration_since(UNIX_EPOCH)
318        .map(|d| d.as_nanos() as u64)
319        .unwrap_or(0)
320}
321
322fn trace_now() -> String {
323    use std::time::{SystemTime, UNIX_EPOCH};
324    let now = SystemTime::now()
325        .duration_since(UNIX_EPOCH)
326        .unwrap_or_default();
327    let total_secs = now.as_secs();
328    let micros = now.subsec_micros();
329    let secs_in_day = total_secs % 86400;
330    let hours = secs_in_day / 3600;
331    let mins = (secs_in_day % 3600) / 60;
332    let secs = secs_in_day % 60;
333    format!("{:02}:{:02}:{:02}.{:06}", hours, mins, secs, micros)
334}
335
336fn json_escape(s: &str) -> String {
337    s.replace('\\', "\\\\").replace('"', "\\\"")
338}
339
340fn trace2_append_json_line(path: &str, line: &str) -> io::Result<()> {
341    let mut file = std::fs::OpenOptions::new()
342        .create(true)
343        .append(true)
344        .open(path)?;
345    writeln!(file, "{line}")
346}
347
348#[cfg(unix)]
349fn is_foreground_stderr(stderr: &io::Stderr) -> bool {
350    use std::os::unix::io::AsRawFd;
351    let fd = stderr.as_raw_fd();
352    // SAFETY: libc tcgetpgrp/getpgid match Git `is_foreground_fd`.
353    unsafe {
354        let tpgrp = libc::tcgetpgrp(fd);
355        if tpgrp < 0 {
356            return true;
357        }
358        libc::getpgid(0) == tpgrp
359    }
360}
361
362#[cfg(not(unix))]
363fn is_foreground_stderr(_stderr: &io::Stderr) -> bool {
364    true
365}
366
367/// Run `test-tool progress`: read script lines from stdin, write progress to stderr.
368///
369/// When `GIT_TRACE2_EVENT` is set to a path, emits `region_enter` / `data` / `region_leave` lines
370/// compatible with `test_region` and t0500 greps.
371pub fn run() -> io::Result<()> {
372    PROGRESS_TEST_NS.set(0);
373
374    let trace_path = std::env::var("GIT_TRACE2_EVENT")
375        .ok()
376        .filter(|s| !s.is_empty());
377
378    let stdin = io::stdin();
379    let mut progress: Option<Progress> = None;
380    let mut title_storage: Vec<String> = Vec::new();
381
382    for line in stdin.lock().lines() {
383        let line = line?;
384        if let Some(rest) = line.strip_prefix("start ") {
385            let mut parts = rest.splitn(2, |c: char| c.is_ascii_whitespace());
386            let total_str = parts.next().unwrap_or("");
387            let total: u64 = total_str.parse().map_err(|e| {
388                io::Error::new(
389                    io::ErrorKind::InvalidInput,
390                    format!("invalid start total: {e}"),
391                )
392            })?;
393            let title: String = match parts.next() {
394                None | Some("") => "Working hard".to_string(),
395                Some(t) => {
396                    title_storage.push(t.to_string());
397                    t.to_string()
398                }
399            };
400            if let Some(path) = trace_path.as_deref() {
401                trace2_append_json_line(
402                    path,
403                    &format!(
404                        r#"{{"event":"region_enter","sid":"grit-0","time":"{}","category":"progress","label":"{}"}}"#,
405                        trace_now(),
406                        json_escape(&title)
407                    ),
408                )?;
409            }
410            progress = Some(Progress::new(title, total));
411        } else if let Some(rest) = line.strip_prefix("progress ") {
412            let n: u64 = rest.trim().parse().map_err(|e| {
413                io::Error::new(
414                    io::ErrorKind::InvalidInput,
415                    format!("invalid progress value: {e}"),
416                )
417            })?;
418            if let Some(ref mut p) = progress {
419                p.display_progress(n)?;
420            }
421        } else if let Some(rest) = line.strip_prefix("throughput ") {
422            let mut it = rest.split_whitespace();
423            let byte_count: u64 = it
424                .next()
425                .ok_or_else(|| {
426                    io::Error::new(io::ErrorKind::InvalidInput, "throughput: missing bytes")
427                })?
428                .parse()
429                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e}")))?;
430            let test_ms: u64 = it
431                .next()
432                .ok_or_else(|| {
433                    io::Error::new(io::ErrorKind::InvalidInput, "throughput: missing millis")
434                })?
435                .parse()
436                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e}")))?;
437            PROGRESS_TEST_NS.set(test_ms.saturating_mul(1_000_000));
438            let global_update = progress.as_ref().is_some_and(|p| p.force_update);
439            if let Some(ref mut p) = progress {
440                p.display_throughput(byte_count, global_update)?;
441            }
442        } else if line == "update" {
443            if let Some(ref mut p) = progress {
444                p.force_update = true;
445            }
446        } else if line == "stop" {
447            if let Some(mut p) = progress.take() {
448                p.stop(trace_path.as_deref())?;
449            }
450        } else {
451            return Err(io::Error::new(
452                io::ErrorKind::InvalidInput,
453                format!("invalid input: '{line}'"),
454            ));
455        }
456    }
457
458    Ok(())
459}