Skip to main content

gkit_core/
git.rs

1//! Thin abstraction over invoking `git`, so the checks are unit-testable without
2//! a real repository. The real impl shells out to `git -C <dir> …`; tests use a
3//! `FakeGit` keyed by the command's args.
4
5use std::path::Path;
6use std::process::Command;
7
8/// Captured result of a single git invocation.
9#[derive(Clone, Debug)]
10pub struct GitOutput {
11    pub stdout: String,
12    pub stderr: String,
13    /// True when git exited 0.
14    pub success: bool,
15}
16
17impl GitOutput {
18    /// stdout with surrounding whitespace trimmed.
19    pub fn trimmed(&self) -> &str {
20        self.stdout.trim()
21    }
22}
23
24/// Anything that can run a git command in a directory.
25pub trait Git {
26    /// Run `git -C <dir> <args…>` and capture stdout/stderr/exit status.
27    fn run(&self, dir: &Path, args: &[&str]) -> GitOutput;
28}
29
30/// Real implementation: shells out to the system `git`.
31pub struct SystemGit;
32
33impl Git for SystemGit {
34    fn run(&self, dir: &Path, args: &[&str]) -> GitOutput {
35        match Command::new("git").arg("-C").arg(dir).args(args).output() {
36            Ok(o) => GitOutput {
37                stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
38                stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
39                success: o.status.success(),
40            },
41            Err(e) => GitOutput {
42                stdout: String::new(),
43                stderr: format!("failed to run git: {e}"),
44                success: false,
45            },
46        }
47    }
48}
49
50#[cfg(test)]
51pub mod test_support {
52    use super::*;
53    use std::collections::HashMap;
54
55    /// Deterministic fake `Git`, keyed by the space-joined args.
56    #[derive(Default)]
57    pub struct FakeGit {
58        responses: HashMap<String, GitOutput>,
59    }
60
61    impl FakeGit {
62        pub fn new() -> Self {
63            Self::default()
64        }
65
66        /// Register a successful (exit 0) response for the given args (space-joined).
67        pub fn ok(mut self, args: &str, stdout: &str) -> Self {
68            self.responses.insert(
69                args.to_string(),
70                GitOutput {
71                    stdout: stdout.to_string(),
72                    stderr: String::new(),
73                    success: true,
74                },
75            );
76            self
77        }
78
79        /// Register a failing (exit != 0) response for the given args.
80        pub fn fail(mut self, args: &str) -> Self {
81            self.responses.insert(
82                args.to_string(),
83                GitOutput {
84                    stdout: String::new(),
85                    stderr: String::new(),
86                    success: false,
87                },
88            );
89            self
90        }
91
92        /// Register a successful response scoped to a specific directory (display
93        /// string), so recursion over multiple repos can be tested.
94        pub fn ok_in(mut self, dir: &str, args: &str, stdout: &str) -> Self {
95            self.responses.insert(
96                format!("{dir}\u{0}{args}"),
97                GitOutput {
98                    stdout: stdout.to_string(),
99                    stderr: String::new(),
100                    success: true,
101                },
102            );
103            self
104        }
105    }
106
107    impl Git for FakeGit {
108        fn run(&self, dir: &Path, args: &[&str]) -> GitOutput {
109            let joined = args.join(" ");
110            // Normalize separators to `/` so directory-scoped keys (always written
111            // with `/` in tests) match on Windows too, where `Path::join` yields `\`.
112            let dir_disp = dir.display().to_string().replace('\\', "/");
113            let dir_key = format!("{dir_disp}\u{0}{joined}");
114            // Prefer a directory-scoped response, else fall back to an args-only one.
115            self.responses
116                .get(&dir_key)
117                .or_else(|| self.responses.get(&joined))
118                .cloned()
119                .unwrap_or(GitOutput {
120                    stdout: String::new(),
121                    stderr: format!("FakeGit: no response for `git {joined}` in {dir_disp}"),
122                    success: false,
123                })
124        }
125    }
126}