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}