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 tp = self.throughput.as_mut().unwrap();
220        tp.curr_total = total;
221
222        if now_ns.saturating_sub(tp.prev_ns) <= 500_000_000 {
223            return Ok(());
224        }
225
226        let misecs: u32 = (((now_ns - tp.prev_ns) as u128 * 4398) >> 32) as u32;
227        let count = total.saturating_sub(tp.prev_total);
228        tp.prev_total = total;
229        tp.prev_ns = now_ns;
230        tp.avg_bytes = tp.avg_bytes.saturating_add(count);
231        tp.avg_misecs = tp.avg_misecs.saturating_add(u64::from(misecs));
232        let rate = if tp.avg_misecs > 0 {
233            (tp.avg_bytes / tp.avg_misecs) as u32
234        } else {
235            0
236        };
237        tp.avg_bytes = tp
238            .avg_bytes
239            .saturating_sub(u64::from(tp.last_bytes[tp.idx]));
240        tp.avg_misecs = tp
241            .avg_misecs
242            .saturating_sub(u64::from(tp.last_misecs[tp.idx]));
243        tp.last_bytes[tp.idx] = count as u32;
244        tp.last_misecs[tp.idx] = misecs;
245        tp.idx = (tp.idx + 1) % TP_IDX_MAX;
246
247        tp.display = throughput_display(total, rate);
248
249        if self.last_value.is_some() && global_update {
250            let n = self.last_value.unwrap_or(0);
251            self.force_update = true;
252            self.display_progress(n)?;
253        }
254        Ok(())
255    }
256
257    fn force_last_update(&mut self, msg: &str) -> io::Result<()> {
258        let now_ns = self.now_ns();
259        if let Some(tp) = self.throughput.as_mut() {
260            let misecs: u32 =
261                (((now_ns.saturating_sub(self.start_ns)) as u128 * 4398) >> 32) as u32;
262            let rate = if misecs > 0 {
263                (tp.curr_total / u64::from(misecs)) as u32
264            } else {
265                0
266            };
267            tp.display = throughput_display(tp.curr_total, rate);
268        }
269        self.force_update = true;
270        let n = self.last_value.unwrap_or(0);
271        let done = format!(", {msg}.\n");
272        self.render_line(n, Some(&done))?;
273        Ok(())
274    }
275
276    fn stop(&mut self, trace_path: Option<&str>) -> io::Result<()> {
277        if self.last_value.is_some() {
278            self.force_last_update("done")?;
279        }
280        if let Some(path) = trace_path {
281            trace2_append_json_line(
282                path,
283                &format!(
284                    r#"{{"event":"data","sid":"grit-0","time":"{}","category":"progress","key":"total_objects","value":"{}"}}"#,
285                    trace_now(),
286                    self.total
287                ),
288            )?;
289            if let Some(tp) = &self.throughput {
290                trace2_append_json_line(
291                    path,
292                    &format!(
293                        r#"{{"event":"data","sid":"grit-0","time":"{}","category":"progress","key":"total_bytes","value":"{}"}}"#,
294                        trace_now(),
295                        tp.curr_total
296                    ),
297                )?;
298            }
299            trace2_append_json_line(
300                path,
301                &format!(
302                    r#"{{"event":"region_leave","sid":"grit-0","time":"{}","category":"progress","label":"{}","t_rel":0.0}}"#,
303                    trace_now(),
304                    json_escape(&self.title)
305                ),
306            )?;
307        }
308        Ok(())
309    }
310}
311
312fn nanotime() -> u64 {
313    use std::time::{SystemTime, UNIX_EPOCH};
314    SystemTime::now()
315        .duration_since(UNIX_EPOCH)
316        .map(|d| d.as_nanos() as u64)
317        .unwrap_or(0)
318}
319
320fn trace_now() -> String {
321    use std::time::{SystemTime, UNIX_EPOCH};
322    let now = SystemTime::now()
323        .duration_since(UNIX_EPOCH)
324        .unwrap_or_default();
325    let total_secs = now.as_secs();
326    let micros = now.subsec_micros();
327    let secs_in_day = total_secs % 86400;
328    let hours = secs_in_day / 3600;
329    let mins = (secs_in_day % 3600) / 60;
330    let secs = secs_in_day % 60;
331    format!("{:02}:{:02}:{:02}.{:06}", hours, mins, secs, micros)
332}
333
334fn json_escape(s: &str) -> String {
335    s.replace('\\', "\\\\").replace('"', "\\\"")
336}
337
338fn trace2_append_json_line(path: &str, line: &str) -> io::Result<()> {
339    let mut file = std::fs::OpenOptions::new()
340        .create(true)
341        .append(true)
342        .open(path)?;
343    writeln!(file, "{line}")
344}
345
346#[cfg(unix)]
347fn is_foreground_stderr(stderr: &io::Stderr) -> bool {
348    use std::os::unix::io::AsRawFd;
349    let fd = stderr.as_raw_fd();
350    // SAFETY: libc tcgetpgrp/getpgid match Git `is_foreground_fd`.
351    unsafe {
352        let tpgrp = libc::tcgetpgrp(fd);
353        if tpgrp < 0 {
354            return true;
355        }
356        libc::getpgid(0) == tpgrp
357    }
358}
359
360#[cfg(not(unix))]
361fn is_foreground_stderr(_stderr: &io::Stderr) -> bool {
362    true
363}
364
365/// Run `test-tool progress`: read script lines from stdin, write progress to stderr.
366///
367/// When `GIT_TRACE2_EVENT` is set to a path, emits `region_enter` / `data` / `region_leave` lines
368/// compatible with `test_region` and t0500 greps.
369pub fn run() -> io::Result<()> {
370    PROGRESS_TEST_NS.set(0);
371
372    let trace_path = std::env::var("GIT_TRACE2_EVENT")
373        .ok()
374        .filter(|s| !s.is_empty());
375
376    let stdin = io::stdin();
377    let mut progress: Option<Progress> = None;
378    let mut title_storage: Vec<String> = Vec::new();
379
380    for line in stdin.lock().lines() {
381        let line = line?;
382        if let Some(rest) = line.strip_prefix("start ") {
383            let mut parts = rest.splitn(2, |c: char| c.is_ascii_whitespace());
384            let total_str = parts.next().unwrap_or("");
385            let total: u64 = total_str.parse().map_err(|e| {
386                io::Error::new(
387                    io::ErrorKind::InvalidInput,
388                    format!("invalid start total: {e}"),
389                )
390            })?;
391            let title: String = match parts.next() {
392                None | Some("") => "Working hard".to_string(),
393                Some(t) => {
394                    title_storage.push(t.to_string());
395                    title_storage.last().unwrap().clone()
396                }
397            };
398            if let Some(path) = trace_path.as_deref() {
399                trace2_append_json_line(
400                    path,
401                    &format!(
402                        r#"{{"event":"region_enter","sid":"grit-0","time":"{}","category":"progress","label":"{}"}}"#,
403                        trace_now(),
404                        json_escape(&title)
405                    ),
406                )?;
407            }
408            progress = Some(Progress::new(title, total));
409        } else if let Some(rest) = line.strip_prefix("progress ") {
410            let n: u64 = rest.trim().parse().map_err(|e| {
411                io::Error::new(
412                    io::ErrorKind::InvalidInput,
413                    format!("invalid progress value: {e}"),
414                )
415            })?;
416            if let Some(ref mut p) = progress {
417                p.display_progress(n)?;
418            }
419        } else if let Some(rest) = line.strip_prefix("throughput ") {
420            let mut it = rest.split_whitespace();
421            let byte_count: u64 = it
422                .next()
423                .ok_or_else(|| {
424                    io::Error::new(io::ErrorKind::InvalidInput, "throughput: missing bytes")
425                })?
426                .parse()
427                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e}")))?;
428            let test_ms: u64 = it
429                .next()
430                .ok_or_else(|| {
431                    io::Error::new(io::ErrorKind::InvalidInput, "throughput: missing millis")
432                })?
433                .parse()
434                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e}")))?;
435            PROGRESS_TEST_NS.set(test_ms.saturating_mul(1_000_000));
436            let global_update = progress.as_ref().is_some_and(|p| p.force_update);
437            if let Some(ref mut p) = progress {
438                p.display_throughput(byte_count, global_update)?;
439            }
440        } else if line == "update" {
441            if let Some(ref mut p) = progress {
442                p.force_update = true;
443            }
444        } else if line == "stop" {
445            if let Some(mut p) = progress.take() {
446                p.stop(trace_path.as_deref())?;
447            }
448        } else {
449            return Err(io::Error::new(
450                io::ErrorKind::InvalidInput,
451                format!("invalid input: '{line}'"),
452            ));
453        }
454    }
455
456    Ok(())
457}