Skip to main content

nils_common/
git.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Output, Stdio};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum GitContextError {
7    GitNotFound,
8    NotRepository,
9}
10
11pub fn run_output(args: &[&str]) -> io::Result<Output> {
12    run_output_inner(None, args)
13}
14
15pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
16    run_output_inner(Some(cwd), args)
17}
18
19pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
20    run_status_quiet_inner(None, args)
21}
22
23pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
24    run_status_quiet_inner(Some(cwd), args)
25}
26
27pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
28    run_status_inherit_inner(None, args)
29}
30
31pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
32    run_status_inherit_inner(Some(cwd), args)
33}
34
35pub fn is_git_available() -> bool {
36    run_status_quiet(&["--version"])
37        .map(|status| status.success())
38        .unwrap_or(false)
39}
40
41pub fn require_repo() -> Result<(), GitContextError> {
42    require_context(None, &["rev-parse", "--git-dir"])
43}
44
45pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
46    require_context(Some(cwd), &["rev-parse", "--git-dir"])
47}
48
49pub fn require_work_tree() -> Result<(), GitContextError> {
50    require_context(None, &["rev-parse", "--is-inside-work-tree"])
51}
52
53pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
54    require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
55}
56
57pub fn is_inside_work_tree() -> io::Result<bool> {
58    Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
59}
60
61pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
62    Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
63}
64
65pub fn is_git_repo() -> io::Result<bool> {
66    Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
67}
68
69pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
70    Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
71}
72
73pub fn repo_root() -> io::Result<Option<PathBuf>> {
74    let output = run_output(&["rev-parse", "--show-toplevel"])?;
75    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
76}
77
78pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
79    let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
80    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
81}
82
83pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
84    let output = run_output(&rev_parse_args(args))?;
85    Ok(trimmed_stdout_if_success(&output))
86}
87
88pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
89    let output = run_output_in(cwd, &rev_parse_args(args))?;
90    Ok(trimmed_stdout_if_success(&output))
91}
92
93fn run_output_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<Output> {
94    let mut cmd = Command::new("git");
95    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
96    if let Some(cwd) = cwd {
97        cmd.current_dir(cwd);
98    }
99    cmd.output()
100}
101
102fn run_status_quiet_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
103    let mut cmd = Command::new("git");
104    cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
105    if let Some(cwd) = cwd {
106        cmd.current_dir(cwd);
107    }
108    cmd.status()
109}
110
111fn run_status_inherit_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
112    let mut cmd = Command::new("git");
113    cmd.args(args)
114        .stdout(Stdio::inherit())
115        .stderr(Stdio::inherit());
116    if let Some(cwd) = cwd {
117        cmd.current_dir(cwd);
118    }
119    cmd.status()
120}
121
122fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
123    if !is_git_available() {
124        return Err(GitContextError::GitNotFound);
125    }
126
127    let in_context = match cwd {
128        Some(cwd) => run_status_quiet_in(cwd, probe_args),
129        None => run_status_quiet(probe_args),
130    }
131    .map(|status| status.success())
132    .unwrap_or(false);
133
134    if in_context {
135        Ok(())
136    } else {
137        Err(GitContextError::NotRepository)
138    }
139}
140
141fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
142    let mut full = Vec::with_capacity(args.len() + 1);
143    full.push("rev-parse");
144    full.extend_from_slice(args);
145    full
146}
147
148fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
149    if !output.status.success() {
150        return None;
151    }
152
153    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
154    if trimmed.is_empty() {
155        None
156    } else {
157        Some(trimmed)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
165    use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
166    use pretty_assertions::assert_eq;
167    use tempfile::TempDir;
168
169    #[test]
170    fn run_output_in_preserves_nonzero_status() {
171        let repo = init_repo_with(InitRepoOptions::new());
172
173        let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
174            .expect("run output in repo");
175
176        assert!(!output.status.success());
177        assert!(!output.stderr.is_empty());
178    }
179
180    #[test]
181    fn run_status_quiet_in_returns_success_and_failure_statuses() {
182        let repo = init_repo_with(InitRepoOptions::new());
183
184        let ok =
185            run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
186        let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
187            .expect("status failure");
188
189        assert!(ok.success());
190        assert!(!bad.success());
191    }
192
193    #[test]
194    fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
195        let repo = init_repo_with(InitRepoOptions::new());
196        let outside = TempDir::new().expect("tempdir");
197
198        assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
199        assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
200        assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
201        assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
202    }
203
204    #[test]
205    fn repo_root_in_returns_root_or_none() {
206        let repo = init_repo_with(InitRepoOptions::new());
207        let outside = TempDir::new().expect("tempdir");
208        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
209            .trim()
210            .to_string();
211
212        assert_eq!(
213            repo_root_in(repo.path()).expect("repo_root_in repo"),
214            Some(expected_root.into())
215        );
216        assert_eq!(
217            repo_root_in(outside.path()).expect("repo_root_in outside"),
218            None
219        );
220    }
221
222    #[test]
223    fn rev_parse_in_returns_value_or_none() {
224        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
225        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
226            .trim()
227            .to_string();
228
229        assert_eq!(
230            rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
231            Some(head)
232        );
233        assert_eq!(
234            rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
235                .expect("rev_parse missing ref"),
236            None
237        );
238    }
239
240    #[test]
241    fn cwd_wrappers_delegate_to_in_variants() {
242        let lock = GlobalStateLock::new();
243        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
244        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
245        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
246            .trim()
247            .to_string();
248        let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
249            .trim()
250            .to_string();
251
252        assert!(is_git_repo().expect("is_git_repo"));
253        assert!(is_inside_work_tree().expect("is_inside_work_tree"));
254        assert_eq!(require_repo(), Ok(()));
255        assert_eq!(require_work_tree(), Ok(()));
256        assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
257        assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
258    }
259
260    #[test]
261    fn require_work_tree_in_reports_missing_git_or_repo_state() {
262        let lock = GlobalStateLock::new();
263        let outside = TempDir::new().expect("tempdir");
264        let empty = TempDir::new().expect("tempdir");
265        let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
266
267        assert_eq!(
268            require_work_tree_in(outside.path()),
269            Err(GitContextError::GitNotFound)
270        );
271    }
272
273    #[test]
274    fn require_repo_and_work_tree_in_report_context_readiness() {
275        let repo = init_repo_with(InitRepoOptions::new());
276        let outside = TempDir::new().expect("tempdir");
277
278        assert_eq!(require_repo_in(repo.path()), Ok(()));
279        assert_eq!(require_work_tree_in(repo.path()), Ok(()));
280        assert_eq!(
281            require_repo_in(outside.path()),
282            Err(GitContextError::NotRepository)
283        );
284        assert_eq!(
285            require_work_tree_in(outside.path()),
286            Err(GitContextError::NotRepository)
287        );
288    }
289}