Skip to main content

amont_runtime/
git.rs

1//! Thin wrappers over the `git` calls the hooks make.
2
3use std::process::{Command, Stdio};
4
5/// stdout of a git command, trimmed. `None` when git itself failed — which the
6/// hooks treat as "cannot tell, do not block", never as "empty".
7pub fn stdout(args: &[&str]) -> Option<String> {
8    let out = Command::new("git")
9        .args(args)
10        .stderr(Stdio::null())
11        .output()
12        .ok()?;
13    if !out.status.success() {
14        return None;
15    }
16    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
17}
18
19/// The same, run inside `dir`.
20///
21/// The dashboard asks about repositories it is not standing in, and must get
22/// the answer git would give THERE — config is per-repository, so asking from
23/// the wrong directory returns the wrong severity.
24pub fn stdout_in(dir: &std::path::Path, args: &[&str]) -> Option<String> {
25    let out = Command::new("git")
26        .arg("-C")
27        .arg(dir)
28        .args(args)
29        .stderr(Stdio::null())
30        .output()
31        .ok()?;
32    if !out.status.success() {
33        return None;
34    }
35    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
36}
37
38/// stdout of a git command that itself reads a list from stdin — `diff-tree
39/// --stdin`, fed a list of commits, is the only caller today. Lossy but
40/// untrimmed: every line is a path, and the caller trims those itself.
41pub fn stdout_piped(args: &[&str], stdin: &str) -> Option<String> {
42    use std::io::Write;
43    let mut child = Command::new("git")
44        .args(args)
45        .stdin(Stdio::piped())
46        .stdout(Stdio::piped())
47        .stderr(Stdio::null())
48        .spawn()
49        .ok()?;
50    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
51    let out = child.wait_with_output().ok()?;
52    out.status
53        .success()
54        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
55}
56
57/// As `stdout_piped`, but returning the RAW bytes.
58///
59/// Needed by the one caller that must both feed git a list on stdin and read a
60/// `-z` path list back — `diff-tree --stdin -z`. `stdout_paths` cannot serve it
61/// (no stdin) and `stdout_piped` cannot either (lossy `String`, and the NUL
62/// separators are the whole point).
63pub fn stdout_piped_raw(args: &[&str], stdin: &str) -> Option<Vec<u8>> {
64    use std::io::Write;
65    let mut child = Command::new("git")
66        .args(args)
67        .stdin(Stdio::piped())
68        .stdout(Stdio::piped())
69        .stderr(Stdio::null())
70        .spawn()
71        .ok()?;
72    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
73    let out = child.wait_with_output().ok()?;
74    out.status.success().then_some(out.stdout)
75}
76
77/// As `stdout_piped`, but inside `dir` and taking raw bytes.
78///
79/// `-C dir` matters for `hash-object`: a repository configured for SHA-256
80/// computes a different id than the default, so the identity has to be asked
81/// of THAT repository. Bytes rather than `&str` because the input is a file we
82/// have already read and must not re-encode.
83pub fn stdout_piped_in(dir: &std::path::Path, args: &[&str], stdin: &[u8]) -> Option<String> {
84    use std::io::Write;
85    let mut child = Command::new("git")
86        .arg("-C")
87        .arg(dir)
88        .args(args)
89        .stdin(Stdio::piped())
90        .stdout(Stdio::piped())
91        .stderr(Stdio::null())
92        .spawn()
93        .ok()?;
94    child.stdin.take()?.write_all(stdin).ok()?;
95    let out = child.wait_with_output().ok()?;
96    out.status
97        .success()
98        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
99}
100
101/// Raw stdout, untrimmed and not lossy — for a patch, where a trailing newline
102/// and any byte in a binary hunk are load-bearing.
103pub fn stdout_raw(args: &[&str]) -> Option<Vec<u8>> {
104    let out = Command::new("git")
105        .args(args)
106        .stderr(Stdio::null())
107        .output()
108        .ok()?;
109    out.status.success().then_some(out.stdout)
110}
111
112/// Everything a git command said: its exit code, its stdout and its stderr.
113pub struct Output {
114    pub code: i32,
115    pub stdout: String,
116    pub stderr: String,
117}
118
119/// A git command's full result, for the caller that must tell one kind of
120/// failure from another.
121///
122/// [`stdout`] collapses every non-zero exit to `None` and discards stderr,
123/// which is the right shape for "cannot tell, do not block". It is the wrong
124/// shape for reading configuration: `git config --get` exits **1** for a key
125/// nobody set and **128** for a key set to something git itself refuses to
126/// parse, and those two must not become the same answer — one is a default,
127/// the other is a mistake somebody needs to be told about. See `config`.
128pub fn output(args: &[&str]) -> Option<Output> {
129    let out = Command::new("git")
130        .args(args)
131        .stdin(Stdio::null())
132        .output()
133        .ok()?;
134    Some(Output {
135        // A process killed by a signal has no code. Treat that as "git did not
136        // answer" rather than inventing one; the caller falls back.
137        code: out.status.code()?,
138        stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
139        stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
140    })
141}
142
143/// True when the command exits 0. Output discarded.
144pub fn succeeds(args: &[&str]) -> bool {
145    Command::new("git")
146        .args(args)
147        .stdin(Stdio::null())
148        .stdout(Stdio::null())
149        .stderr(Stdio::null())
150        .status()
151        .map(|s| s.success())
152        .unwrap_or(false)
153}
154
155/// A path list from `diff --name-only`, `diff-tree --name-only` or
156/// `ls-files` — commands whose output is meant to be split into individual
157/// paths, never just read as one blob.
158///
159/// By default git QUOTES any "unusual" byte in a path, non-ASCII included:
160/// `é.json` prints as `"\303\251.json"`. Reading that line as a literal path
161/// looks up a file that does not exist — the caller then treats real,
162/// unstaged content as absent, which is how `StagedOnly` used to lose it.
163/// `-z` disables quoting entirely and NUL-terminates each entry instead, so
164/// there is no escaping left to get wrong. Inserted right after the
165/// subcommand (`args[0]`), which is always a valid position for it on every
166/// command this is used for.
167pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
168    let (first, rest) = args.split_first()?;
169    let mut argv = Vec::with_capacity(args.len() + 1);
170    argv.push(*first);
171    argv.push("-z");
172    argv.extend_from_slice(rest);
173    stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
174}
175
176/// The parsing half of [`stdout_paths`], split out so it can be tested on
177/// literal bytes rather than a real git process — including the byte
178/// sequence a QUOTED path would have produced under the old line-splitting
179/// approach, to prove `-z` output is never reinterpreted that way.
180pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
181    raw.split(|&b| b == 0)
182        .filter(|s| !s.is_empty())
183        .map(|s| String::from_utf8_lossy(s).into_owned())
184        .collect()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn splits_on_nul_and_drops_the_trailing_empty_segment() {
193        assert_eq!(
194            split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
195            vec!["src/main.rs", "Cargo.toml"]
196        );
197    }
198
199    #[test]
200    fn empty_input_is_no_paths() {
201        assert_eq!(split_nul_paths(b""), Vec::<String>::new());
202    }
203
204    /// The exact bug this exists to prevent: under `--name-only` without
205    /// `-z`, git would have printed `é.json` as the quoted, LINE-oriented
206    /// text `"\303\251.json"` — literal backslashes, digits and quotes, nine
207    /// bytes standing in for the original two-byte UTF-8 sequence. `-z`
208    /// output carries the real UTF-8 bytes of the path with no such
209    /// reinterpretation, so splitting on NUL must hand them back unchanged.
210    #[test]
211    fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
212        let mut raw = "é.json".as_bytes().to_vec();
213        raw.push(0);
214        let got = split_nul_paths(&raw);
215        assert_eq!(got, vec!["é.json".to_string()]);
216        assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
217    }
218}