1use std::collections::BTreeMap;
2use std::ffi::{OsStr, OsString};
3use std::path::{Path, PathBuf};
4use std::process::{Command, ExitStatus, Output};
5
6use crate::{OutpostError, OutpostResult};
7
8#[cfg(unix)]
9use std::os::unix::ffi::OsStrExt;
10#[cfg(unix)]
11use std::os::unix::process::ExitStatusExt;
12
13#[derive(Clone)]
14pub struct GitInvoker {
15 cwd: PathBuf,
16 env: BTreeMap<OsString, OsString>,
17 #[cfg(any(test, feature = "test-helpers"))]
18 argv_log: std::sync::Arc<std::sync::Mutex<Vec<Vec<OsString>>>>,
19}
20
21impl GitInvoker {
22 pub fn at(cwd: impl Into<PathBuf>) -> Self {
23 Self {
24 cwd: cwd.into(),
25 env: BTreeMap::new(),
26 #[cfg(any(test, feature = "test-helpers"))]
27 argv_log: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
28 }
29 }
30
31 pub fn with_env(mut self, key: impl Into<OsString>, val: impl Into<OsString>) -> Self {
32 self.env.insert(key.into(), val.into());
33 self
34 }
35
36 pub fn cwd(&self) -> &Path {
37 &self.cwd
38 }
39
40 pub fn run_capture<I, S>(&self, args: I) -> OutpostResult<String>
41 where
42 I: IntoIterator<Item = S>,
43 S: AsRef<OsStr>,
44 {
45 let argv = collect_argv(args);
46 let output = self.output(&argv)?;
47 match output.status.code() {
48 Some(0) => Ok(trimmed_lossy(&output.stdout)),
49 Some(code) => Err(git_failed(&argv, code, &output.stderr)),
50 None => Err(git_terminated(&argv, output.status)),
51 }
52 }
53
54 pub fn run_check<I, S>(&self, args: I) -> OutpostResult<()>
55 where
56 I: IntoIterator<Item = S>,
57 S: AsRef<OsStr>,
58 {
59 let argv = collect_argv(args);
60 let output = self.output(&argv)?;
61 match output.status.code() {
62 Some(0) => Ok(()),
63 Some(code) => Err(git_failed(&argv, code, &output.stderr)),
64 None => Err(git_terminated(&argv, output.status)),
65 }
66 }
67
68 pub fn run_status<I, S>(&self, args: I) -> OutpostResult<bool>
69 where
70 I: IntoIterator<Item = S>,
71 S: AsRef<OsStr>,
72 {
73 let argv = collect_argv(args);
74 let output = self.output(&argv)?;
75 match output.status.code() {
76 Some(0) => Ok(true),
77 Some(1) => Ok(false),
78 Some(code) => Err(git_failed(&argv, code, &output.stderr)),
79 None => Err(git_terminated(&argv, output.status)),
80 }
81 }
82
83 #[cfg(any(test, feature = "test-helpers"))]
84 pub fn argv_log(&self) -> Vec<Vec<OsString>> {
85 self.argv_log.lock().expect("argv log poisoned").clone()
86 }
87
88 fn output(&self, argv: &[OsString]) -> OutpostResult<Output> {
89 #[cfg(any(test, feature = "test-helpers"))]
90 self.argv_log
91 .lock()
92 .expect("argv log poisoned")
93 .push(argv.to_vec());
94
95 Command::new("git")
96 .current_dir(crate::path::git_path(&self.cwd))
97 .envs(&self.env)
98 .args(argv)
100 .output()
101 .map_err(|source| OutpostError::IoAt {
102 path: self.cwd.clone(),
103 source,
104 })
105 }
106}
107
108fn collect_argv<I, S>(args: I) -> Vec<OsString>
109where
110 I: IntoIterator<Item = S>,
111 S: AsRef<OsStr>,
112{
113 args.into_iter()
114 .map(|arg| arg.as_ref().to_os_string())
115 .collect()
116}
117
118fn git_failed(argv: &[OsString], code: i32, stderr: &[u8]) -> OutpostError {
119 OutpostError::GitFailed {
120 args: display_argv(argv),
121 code,
122 stderr: trimmed_lossy(stderr),
123 }
124}
125
126fn git_terminated(argv: &[OsString], status: ExitStatus) -> OutpostError {
127 OutpostError::GitTerminatedBySignal {
128 args: display_argv(argv),
129 signal_str: signal_str(status),
130 }
131}
132
133fn display_argv(argv: &[OsString]) -> String {
134 let args = argv
135 .iter()
136 .map(|arg| display_arg(arg.as_os_str()))
137 .collect::<Vec<_>>()
138 .join(", ");
139 format!("[{args}]")
140}
141
142#[cfg(unix)]
143fn display_arg(arg: &OsStr) -> String {
144 let mut rendered = String::from("\"");
145 for byte in arg.as_bytes() {
146 for escaped in byte.escape_ascii() {
147 rendered.push(escaped as char);
148 }
149 }
150 rendered.push('"');
151 rendered
152}
153
154#[cfg(windows)]
155fn display_arg(arg: &OsStr) -> String {
156 use std::fmt::Write;
157 use std::os::windows::ffi::OsStrExt;
158
159 let mut rendered = String::from("w\"");
160 for unit in arg.encode_wide() {
161 match char::from_u32(u32::from(unit)) {
162 Some('\\') => rendered.push_str("\\\\"),
163 Some('"') => rendered.push_str("\\\""),
164 Some(c) if !c.is_control() => rendered.push(c),
165 Some(c) => write!(rendered, "\\u{{{:x}}}", c as u32).expect("write to string"),
166 None => write!(rendered, "\\u{{{:x}}}", unit).expect("write to string"),
167 }
168 }
169 rendered.push('"');
170 rendered
171}
172
173#[cfg(not(any(unix, windows)))]
174fn display_arg(arg: &OsStr) -> String {
175 format!("{arg:?}")
176}
177
178fn trimmed_lossy(bytes: &[u8]) -> String {
179 String::from_utf8_lossy(bytes).trim().to_owned()
180}
181
182#[cfg(unix)]
183fn signal_str(status: ExitStatus) -> String {
184 status
185 .signal()
186 .map(|signal| format!(" (signal {signal})"))
187 .unwrap_or_default()
188}
189
190#[cfg(not(unix))]
191fn signal_str(_status: ExitStatus) -> String {
192 String::new()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn run_check_bad_command_preserves_failed_argv() {
201 let git = GitInvoker::at(env!("CARGO_MANIFEST_DIR"));
202 let err = git
203 .run_check([
204 "definitely-not-a-git-outpost-command",
205 "--literal",
206 "value with spaces",
207 ])
208 .expect_err("bad git command should fail");
209
210 match err {
211 OutpostError::GitFailed { args, code, stderr } => {
212 assert_eq!(args, expected_bad_command_argv());
213 assert_ne!(
214 args,
215 r#"["definitely-not-a-git-outpost-command", "--literal", "value", "with", "spaces"]"#
216 );
217 assert_ne!(code, 0);
218 assert!(stderr.contains("git") || stderr.contains("not a git command"));
219 }
220 other => panic!("expected GitFailed, got {other:?}"),
221 }
222 }
223
224 #[cfg(unix)]
225 fn expected_bad_command_argv() -> &'static str {
226 r#"["definitely-not-a-git-outpost-command", "--literal", "value with spaces"]"#
227 }
228
229 #[cfg(windows)]
230 fn expected_bad_command_argv() -> &'static str {
231 r#"[w"definitely-not-a-git-outpost-command", w"--literal", w"value with spaces"]"#
232 }
233
234 #[test]
235 fn run_capture_keeps_leading_dash_value_positional_after_separator() {
236 let git = GitInvoker::at(env!("CARGO_MANIFEST_DIR"));
237 let stdout = git
238 .run_capture(["rev-parse", "--", "--not-a-flag"])
239 .expect("rev-parse should echo positional value");
240
241 assert_eq!(stdout, "--\n--not-a-flag");
242 assert_eq!(
243 git.argv_log(),
244 vec![vec![
245 OsString::from("rev-parse"),
246 OsString::from("--"),
247 OsString::from("--not-a-flag")
248 ]]
249 );
250 }
251
252 #[test]
253 fn run_status_distinguishes_exit_one_from_real_failure() {
254 let git = GitInvoker::at(env!("CARGO_MANIFEST_DIR"));
255
256 assert!(
257 !git.run_status(["rev-parse", "--verify", "--quiet", "refs/heads/missing"])
258 .expect("rev-parse reports missing ref as status false")
259 );
260
261 let err = git
262 .run_status(["ls-tree", "--bad-option", "HEAD"])
263 .expect_err("usage errors should be real failures");
264 assert!(matches!(err, OutpostError::GitFailed { .. }));
265 }
266}