Skip to main content

outpost_core/
git.rs

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            // Keep argv as separate OS strings; no shell parses user input here.
99            .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}