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 has_staged_changes() -> io::Result<bool> {
145    let status = run_status_quiet(&["diff", "--cached", "--quiet", "--"])?;
146    Ok(has_staged_changes_from_status(status))
147}
148
149pub fn has_staged_changes_in(cwd: &Path) -> io::Result<bool> {
150    let status = run_status_quiet_in(cwd, &["diff", "--cached", "--quiet", "--"])?;
151    Ok(has_staged_changes_from_status(status))
152}
153
154pub fn is_git_repo() -> io::Result<bool> {
155    Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
156}
157
158pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
159    Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
160}
161
162pub fn repo_root() -> io::Result<Option<PathBuf>> {
163    let output = run_output(&["rev-parse", "--show-toplevel"])?;
164    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
165}
166
167pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
168    let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
169    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
170}
171
172pub fn repo_root_or_cwd() -> PathBuf {
173    repo_root()
174        .ok()
175        .flatten()
176        .or_else(|| std::env::current_dir().ok())
177        .unwrap_or_else(|| PathBuf::from("."))
178}
179
180pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
181    let output = run_output(&rev_parse_args(args))?;
182    Ok(trimmed_stdout_if_success(&output))
183}
184
185pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
186    let output = run_output_in(cwd, &rev_parse_args(args))?;
187    Ok(trimmed_stdout_if_success(&output))
188}
189
190fn run_output_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<Output> {
191    let mut cmd = Command::new("git");
192    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
193    if let Some(cwd) = cwd {
194        cmd.current_dir(cwd);
195    }
196    cmd.output()
197}
198
199fn run_status_quiet_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
200    let mut cmd = Command::new("git");
201    cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
202    if let Some(cwd) = cwd {
203        cmd.current_dir(cwd);
204    }
205    cmd.status()
206}
207
208fn run_status_inherit_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
209    let mut cmd = Command::new("git");
210    cmd.args(args)
211        .stdout(Stdio::inherit())
212        .stderr(Stdio::inherit());
213    if let Some(cwd) = cwd {
214        cmd.current_dir(cwd);
215    }
216    cmd.status()
217}
218
219fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
220    if !is_git_available() {
221        return Err(GitContextError::GitNotFound);
222    }
223
224    let in_context = match cwd {
225        Some(cwd) => run_status_quiet_in(cwd, probe_args),
226        None => run_status_quiet(probe_args),
227    }
228    .map(|status| status.success())
229    .unwrap_or(false);
230
231    if in_context {
232        Ok(())
233    } else {
234        Err(GitContextError::NotRepository)
235    }
236}
237
238fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
239    let mut full = Vec::with_capacity(args.len() + 1);
240    full.push("rev-parse");
241    full.extend_from_slice(args);
242    full
243}
244
245fn has_staged_changes_from_status(status: ExitStatus) -> bool {
246    match status.code() {
247        Some(0) => false,
248        Some(1) => true,
249        _ => !status.success(),
250    }
251}
252
253fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
254    if !output.status.success() {
255        return None;
256    }
257
258    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
259    if trimmed.is_empty() {
260        None
261    } else {
262        Some(trimmed)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
270    use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
271    use pretty_assertions::assert_eq;
272    use tempfile::TempDir;
273
274    #[test]
275    fn run_output_in_preserves_nonzero_status() {
276        let repo = init_repo_with(InitRepoOptions::new());
277
278        let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
279            .expect("run output in repo");
280
281        assert!(!output.status.success());
282        assert!(!output.stderr.is_empty());
283    }
284
285    #[test]
286    fn run_status_quiet_in_returns_success_and_failure_statuses() {
287        let repo = init_repo_with(InitRepoOptions::new());
288
289        let ok =
290            run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
291        let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
292            .expect("status failure");
293
294        assert!(ok.success());
295        assert!(!bad.success());
296    }
297
298    #[test]
299    fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
300        let repo = init_repo_with(InitRepoOptions::new());
301        let outside = TempDir::new().expect("tempdir");
302
303        assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
304        assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
305        assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
306        assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
307    }
308
309    #[test]
310    fn repo_root_in_returns_root_or_none() {
311        let repo = init_repo_with(InitRepoOptions::new());
312        let outside = TempDir::new().expect("tempdir");
313        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
314            .trim()
315            .to_string();
316
317        assert_eq!(
318            repo_root_in(repo.path()).expect("repo_root_in repo"),
319            Some(expected_root.into())
320        );
321        assert_eq!(
322            repo_root_in(outside.path()).expect("repo_root_in outside"),
323            None
324        );
325    }
326
327    #[test]
328    fn rev_parse_in_returns_value_or_none() {
329        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
330        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
331            .trim()
332            .to_string();
333
334        assert_eq!(
335            rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
336            Some(head)
337        );
338        assert_eq!(
339            rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
340                .expect("rev_parse missing ref"),
341            None
342        );
343    }
344
345    #[test]
346    fn cwd_wrappers_delegate_to_in_variants() {
347        let lock = GlobalStateLock::new();
348        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
349        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
350        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
351            .trim()
352            .to_string();
353        let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
354            .trim()
355            .to_string();
356
357        assert!(is_git_repo().expect("is_git_repo"));
358        assert!(is_inside_work_tree().expect("is_inside_work_tree"));
359        assert!(!has_staged_changes().expect("has_staged_changes"));
360        assert_eq!(require_repo(), Ok(()));
361        assert_eq!(require_work_tree(), Ok(()));
362        assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
363        assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
364    }
365
366    #[test]
367    fn has_staged_changes_in_reports_index_state() {
368        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
369
370        assert!(!has_staged_changes_in(repo.path()).expect("no staged changes"));
371
372        std::fs::write(repo.path().join("a.txt"), "hello\n").expect("write staged file");
373        run_git(repo.path(), &["add", "a.txt"]);
374
375        assert!(has_staged_changes_in(repo.path()).expect("staged changes present"));
376    }
377
378    #[test]
379    fn repo_root_or_cwd_prefers_repo_root_when_available() {
380        let lock = GlobalStateLock::new();
381        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
382        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
383        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
384            .trim()
385            .to_string();
386
387        assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
388    }
389
390    #[test]
391    fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
392        let lock = GlobalStateLock::new();
393        let outside = TempDir::new().expect("tempdir");
394        let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
395
396        let resolved = repo_root_or_cwd()
397            .canonicalize()
398            .expect("canonicalize resolved path");
399        let expected = outside
400            .path()
401            .canonicalize()
402            .expect("canonicalize expected path");
403
404        assert_eq!(resolved, expected);
405    }
406
407    #[test]
408    fn require_work_tree_in_reports_missing_git_or_repo_state() {
409        let lock = GlobalStateLock::new();
410        let outside = TempDir::new().expect("tempdir");
411        let empty = TempDir::new().expect("tempdir");
412        let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
413
414        assert_eq!(
415            require_work_tree_in(outside.path()),
416            Err(GitContextError::GitNotFound)
417        );
418    }
419
420    #[test]
421    fn require_repo_and_work_tree_in_report_context_readiness() {
422        let repo = init_repo_with(InitRepoOptions::new());
423        let outside = TempDir::new().expect("tempdir");
424
425        assert_eq!(require_repo_in(repo.path()), Ok(()));
426        assert_eq!(require_work_tree_in(repo.path()), Ok(()));
427        assert_eq!(
428            require_repo_in(outside.path()),
429            Err(GitContextError::NotRepository)
430        );
431        assert_eq!(
432            require_work_tree_in(outside.path()),
433            Err(GitContextError::NotRepository)
434        );
435    }
436
437    #[test]
438    fn parse_name_status_z_handles_rename_copy_and_modify() {
439        let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
440        let entries = parse_name_status_z(bytes).expect("parse name-status");
441
442        assert_eq!(entries.len(), 3);
443        assert_eq!(entries[0].status_raw, b"R100");
444        assert_eq!(entries[0].path, b"new.txt");
445        assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
446        assert_eq!(entries[1].status_raw, b"C90");
447        assert_eq!(entries[1].path, b"dst.rs");
448        assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
449        assert_eq!(entries[2].status_raw, b"M");
450        assert_eq!(entries[2].path, b"file.txt");
451        assert_eq!(entries[2].old_path, None);
452    }
453
454    #[test]
455    fn parse_name_status_z_errors_on_malformed_output() {
456        let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
457        assert_eq!(err, NameStatusParseError::MalformedOutput);
458        assert_eq!(err.to_string(), "error: malformed name-status output");
459    }
460
461    #[test]
462    fn is_lockfile_path_matches_known_package_manager_lockfiles() {
463        for path in [
464            "yarn.lock",
465            "frontend/package-lock.json",
466            "subdir/pnpm-lock.yaml",
467            "bun.lockb",
468            "bun.lock",
469            "npm-shrinkwrap.json",
470        ] {
471            assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
472        }
473
474        assert!(!is_lockfile_path("Cargo.lock"));
475        assert!(!is_lockfile_path("package-lock.json.bak"));
476    }
477}