Skip to main content

nils_common/
git.rs

1use std::error::Error;
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus, Output, Stdio};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitContextError {
9    GitNotFound,
10    NotRepository,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NameStatusParseError {
15    MalformedOutput,
16}
17
18impl fmt::Display for NameStatusParseError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            NameStatusParseError::MalformedOutput => {
22                write!(f, "error: malformed name-status output")
23            }
24        }
25    }
26}
27
28impl Error for NameStatusParseError {}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct NameStatusZEntry<'a> {
32    pub status_raw: &'a [u8],
33    pub path: &'a [u8],
34    pub old_path: Option<&'a [u8]>,
35}
36
37pub fn parse_name_status_z(buf: &[u8]) -> Result<Vec<NameStatusZEntry<'_>>, NameStatusParseError> {
38    let parts: Vec<&[u8]> = buf
39        .split(|b| *b == 0)
40        .filter(|part| !part.is_empty())
41        .collect();
42    let mut out: Vec<NameStatusZEntry<'_>> = Vec::new();
43    let mut i = 0;
44
45    while i < parts.len() {
46        let status_raw = parts[i];
47        i += 1;
48
49        if matches!(status_raw.first(), Some(b'R' | b'C')) {
50            let old = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
51            let new = *parts
52                .get(i + 1)
53                .ok_or(NameStatusParseError::MalformedOutput)?;
54            i += 2;
55            out.push(NameStatusZEntry {
56                status_raw,
57                path: new,
58                old_path: Some(old),
59            });
60        } else {
61            let file = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
62            i += 1;
63            out.push(NameStatusZEntry {
64                status_raw,
65                path: file,
66                old_path: None,
67            });
68        }
69    }
70
71    Ok(out)
72}
73
74pub fn is_lockfile_path(path: &str) -> bool {
75    let name = Path::new(path)
76        .file_name()
77        .and_then(|segment| segment.to_str())
78        .unwrap_or("");
79    matches!(
80        name,
81        "yarn.lock"
82            | "package-lock.json"
83            | "pnpm-lock.yaml"
84            | "bun.lockb"
85            | "bun.lock"
86            | "npm-shrinkwrap.json"
87    )
88}
89
90pub fn run_output(args: &[&str]) -> io::Result<Output> {
91    run_output_inner(None, args)
92}
93
94pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
95    run_output_inner(Some(cwd), args)
96}
97
98pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
99    run_status_quiet_inner(None, args)
100}
101
102pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
103    run_status_quiet_inner(Some(cwd), args)
104}
105
106pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
107    run_status_inherit_inner(None, args)
108}
109
110pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
111    run_status_inherit_inner(Some(cwd), args)
112}
113
114pub fn is_git_available() -> bool {
115    run_status_quiet(&["--version"])
116        .map(|status| status.success())
117        .unwrap_or(false)
118}
119
120pub fn require_repo() -> Result<(), GitContextError> {
121    require_context(None, &["rev-parse", "--git-dir"])
122}
123
124pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
125    require_context(Some(cwd), &["rev-parse", "--git-dir"])
126}
127
128pub fn require_work_tree() -> Result<(), GitContextError> {
129    require_context(None, &["rev-parse", "--is-inside-work-tree"])
130}
131
132pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
133    require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
134}
135
136pub fn is_inside_work_tree() -> io::Result<bool> {
137    Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
138}
139
140pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
141    Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
142}
143
144pub fn is_git_repo() -> io::Result<bool> {
145    Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
146}
147
148pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
149    Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
150}
151
152pub fn repo_root() -> io::Result<Option<PathBuf>> {
153    let output = run_output(&["rev-parse", "--show-toplevel"])?;
154    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
155}
156
157pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
158    let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
159    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
160}
161
162pub fn repo_root_or_cwd() -> PathBuf {
163    repo_root()
164        .ok()
165        .flatten()
166        .or_else(|| std::env::current_dir().ok())
167        .unwrap_or_else(|| PathBuf::from("."))
168}
169
170pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
171    let output = run_output(&rev_parse_args(args))?;
172    Ok(trimmed_stdout_if_success(&output))
173}
174
175pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
176    let output = run_output_in(cwd, &rev_parse_args(args))?;
177    Ok(trimmed_stdout_if_success(&output))
178}
179
180fn run_output_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<Output> {
181    let mut cmd = Command::new("git");
182    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
183    if let Some(cwd) = cwd {
184        cmd.current_dir(cwd);
185    }
186    cmd.output()
187}
188
189fn run_status_quiet_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
190    let mut cmd = Command::new("git");
191    cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
192    if let Some(cwd) = cwd {
193        cmd.current_dir(cwd);
194    }
195    cmd.status()
196}
197
198fn run_status_inherit_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
199    let mut cmd = Command::new("git");
200    cmd.args(args)
201        .stdout(Stdio::inherit())
202        .stderr(Stdio::inherit());
203    if let Some(cwd) = cwd {
204        cmd.current_dir(cwd);
205    }
206    cmd.status()
207}
208
209fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
210    if !is_git_available() {
211        return Err(GitContextError::GitNotFound);
212    }
213
214    let in_context = match cwd {
215        Some(cwd) => run_status_quiet_in(cwd, probe_args),
216        None => run_status_quiet(probe_args),
217    }
218    .map(|status| status.success())
219    .unwrap_or(false);
220
221    if in_context {
222        Ok(())
223    } else {
224        Err(GitContextError::NotRepository)
225    }
226}
227
228fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
229    let mut full = Vec::with_capacity(args.len() + 1);
230    full.push("rev-parse");
231    full.extend_from_slice(args);
232    full
233}
234
235fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
236    if !output.status.success() {
237        return None;
238    }
239
240    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
241    if trimmed.is_empty() {
242        None
243    } else {
244        Some(trimmed)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
252    use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
253    use pretty_assertions::assert_eq;
254    use tempfile::TempDir;
255
256    #[test]
257    fn run_output_in_preserves_nonzero_status() {
258        let repo = init_repo_with(InitRepoOptions::new());
259
260        let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
261            .expect("run output in repo");
262
263        assert!(!output.status.success());
264        assert!(!output.stderr.is_empty());
265    }
266
267    #[test]
268    fn run_status_quiet_in_returns_success_and_failure_statuses() {
269        let repo = init_repo_with(InitRepoOptions::new());
270
271        let ok =
272            run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
273        let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
274            .expect("status failure");
275
276        assert!(ok.success());
277        assert!(!bad.success());
278    }
279
280    #[test]
281    fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
282        let repo = init_repo_with(InitRepoOptions::new());
283        let outside = TempDir::new().expect("tempdir");
284
285        assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
286        assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
287        assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
288        assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
289    }
290
291    #[test]
292    fn repo_root_in_returns_root_or_none() {
293        let repo = init_repo_with(InitRepoOptions::new());
294        let outside = TempDir::new().expect("tempdir");
295        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
296            .trim()
297            .to_string();
298
299        assert_eq!(
300            repo_root_in(repo.path()).expect("repo_root_in repo"),
301            Some(expected_root.into())
302        );
303        assert_eq!(
304            repo_root_in(outside.path()).expect("repo_root_in outside"),
305            None
306        );
307    }
308
309    #[test]
310    fn rev_parse_in_returns_value_or_none() {
311        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
312        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
313            .trim()
314            .to_string();
315
316        assert_eq!(
317            rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
318            Some(head)
319        );
320        assert_eq!(
321            rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
322                .expect("rev_parse missing ref"),
323            None
324        );
325    }
326
327    #[test]
328    fn cwd_wrappers_delegate_to_in_variants() {
329        let lock = GlobalStateLock::new();
330        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
331        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
332        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
333            .trim()
334            .to_string();
335        let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
336            .trim()
337            .to_string();
338
339        assert!(is_git_repo().expect("is_git_repo"));
340        assert!(is_inside_work_tree().expect("is_inside_work_tree"));
341        assert_eq!(require_repo(), Ok(()));
342        assert_eq!(require_work_tree(), Ok(()));
343        assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
344        assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
345    }
346
347    #[test]
348    fn repo_root_or_cwd_prefers_repo_root_when_available() {
349        let lock = GlobalStateLock::new();
350        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
351        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
352        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
353            .trim()
354            .to_string();
355
356        assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
357    }
358
359    #[test]
360    fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
361        let lock = GlobalStateLock::new();
362        let outside = TempDir::new().expect("tempdir");
363        let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
364
365        let resolved = repo_root_or_cwd()
366            .canonicalize()
367            .expect("canonicalize resolved path");
368        let expected = outside
369            .path()
370            .canonicalize()
371            .expect("canonicalize expected path");
372
373        assert_eq!(resolved, expected);
374    }
375
376    #[test]
377    fn require_work_tree_in_reports_missing_git_or_repo_state() {
378        let lock = GlobalStateLock::new();
379        let outside = TempDir::new().expect("tempdir");
380        let empty = TempDir::new().expect("tempdir");
381        let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
382
383        assert_eq!(
384            require_work_tree_in(outside.path()),
385            Err(GitContextError::GitNotFound)
386        );
387    }
388
389    #[test]
390    fn require_repo_and_work_tree_in_report_context_readiness() {
391        let repo = init_repo_with(InitRepoOptions::new());
392        let outside = TempDir::new().expect("tempdir");
393
394        assert_eq!(require_repo_in(repo.path()), Ok(()));
395        assert_eq!(require_work_tree_in(repo.path()), Ok(()));
396        assert_eq!(
397            require_repo_in(outside.path()),
398            Err(GitContextError::NotRepository)
399        );
400        assert_eq!(
401            require_work_tree_in(outside.path()),
402            Err(GitContextError::NotRepository)
403        );
404    }
405
406    #[test]
407    fn parse_name_status_z_handles_rename_copy_and_modify() {
408        let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
409        let entries = parse_name_status_z(bytes).expect("parse name-status");
410
411        assert_eq!(entries.len(), 3);
412        assert_eq!(entries[0].status_raw, b"R100");
413        assert_eq!(entries[0].path, b"new.txt");
414        assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
415        assert_eq!(entries[1].status_raw, b"C90");
416        assert_eq!(entries[1].path, b"dst.rs");
417        assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
418        assert_eq!(entries[2].status_raw, b"M");
419        assert_eq!(entries[2].path, b"file.txt");
420        assert_eq!(entries[2].old_path, None);
421    }
422
423    #[test]
424    fn parse_name_status_z_errors_on_malformed_output() {
425        let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
426        assert_eq!(err, NameStatusParseError::MalformedOutput);
427        assert_eq!(err.to_string(), "error: malformed name-status output");
428    }
429
430    #[test]
431    fn is_lockfile_path_matches_known_package_manager_lockfiles() {
432        for path in [
433            "yarn.lock",
434            "frontend/package-lock.json",
435            "subdir/pnpm-lock.yaml",
436            "bun.lockb",
437            "bun.lock",
438            "npm-shrinkwrap.json",
439        ] {
440            assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
441        }
442
443        assert!(!is_lockfile_path("Cargo.lock"));
444        assert!(!is_lockfile_path("package-lock.json.bak"));
445    }
446}