Skip to main content

borg/notes/
regress_git.rs

1//! git for regress.rs: every call with a timeout inside one deadline for the whole run, so the
2//! end-of-session job never hangs on a huge repo. Paths come back unquoted (`core.quotePath=false`),
3//! as blame takes them.
4
5use std::io::Read;
6use std::path::Path;
7use std::process::{Child, Command, ExitStatus, Stdio};
8use std::time::{Duration, Instant};
9
10use crate::notes::regress_diff::{self as diff, Hunk};
11
12/// git with a per-call timeout inside one deadline; a call past either, or one that fails, is
13/// `None`, and so is every call once the deadline has passed.
14pub struct Git<'a> {
15    repo: &'a Path,
16    call: Duration,
17    deadline: Instant,
18}
19
20impl<'a> Git<'a> {
21    /// Calls of at most `call` each, all of them within `budget` from now.
22    #[must_use]
23    pub fn new(repo: &'a Path, call: Duration, budget: Duration) -> Git<'a> {
24        Git { repo, call, deadline: Instant::now() + budget }
25    }
26
27    #[must_use]
28    pub fn repo(&self) -> &'a Path {
29        self.repo
30    }
31
32    pub(crate) fn run(&self, args: &[&str]) -> Option<String> {
33        let limit = self.call.min(self.deadline.checked_duration_since(Instant::now())?);
34        if limit.is_zero() {
35            return None;
36        }
37        let mut child = Command::new("git")
38            .arg("-C")
39            .arg(self.repo)
40            .args(["-c", "core.quotePath=false"])
41            .args(args)
42            .stdin(Stdio::null())
43            .stdout(Stdio::piped())
44            .stderr(Stdio::null())
45            .spawn()
46            .ok()?;
47        let mut out = child.stdout.take()?;
48        // Read on a thread: a large output must not block the wait below.
49        let reader = std::thread::spawn(move || {
50            let mut buf = Vec::new();
51            let _ = out.read_to_end(&mut buf);
52            String::from_utf8_lossy(&buf).into_owned()
53        });
54        let status = wait_for(&mut child, limit)?;
55        let text = reader.join().ok()?;
56        status.success().then_some(text)
57    }
58
59    /// The `-U0` hunks of `sha` against `parent`, whitespace changes left out (`regress_diff.rs`).
60    pub(crate) fn hunks(&self, parent: &str, sha: &str) -> Option<Vec<Hunk>> {
61        let args = ["diff", "-U0", "-w", "--ignore-blank-lines", "-M", "--no-color", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/", parent, sha];
62        self.run(&args).map(|t| diff::hunks(&t))
63    }
64}
65
66/// The child's exit status, or `None` (killed) when it runs past `limit`.
67fn wait_for(child: &mut Child, limit: Duration) -> Option<ExitStatus> {
68    let (started, mut nap) = (Instant::now(), Duration::from_millis(1));
69    loop {
70        match child.try_wait() {
71            Ok(Some(s)) => return Some(s),
72            Ok(None) if started.elapsed() < limit => {
73                std::thread::sleep(nap);
74                nap = (nap * 2).min(Duration::from_millis(20));
75            }
76            _ => {
77                let _ = child.kill();
78                let _ = child.wait();
79                return None;
80            }
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn a_call_past_its_limit_or_the_deadline_is_none() {
91        let dir = std::env::temp_dir();
92        assert!(Git::new(&dir, Duration::from_secs(5), Duration::from_secs(5)).run(&["--version"]).is_some_and(|v| v.starts_with("git version")));
93        assert!(Git::new(&dir, Duration::from_secs(5), Duration::ZERO).run(&["--version"]).is_none(), "the run's budget is spent");
94    }
95}