Skip to main content

nils_common/
git.rs

1use crate::process;
2use std::collections::BTreeSet;
3use std::error::Error;
4use std::fmt;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{ExitStatus, Output};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GitContextError {
11    GitNotFound,
12    NotRepository,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum NameStatusParseError {
17    MalformedOutput,
18}
19
20impl fmt::Display for NameStatusParseError {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            NameStatusParseError::MalformedOutput => {
24                write!(f, "error: malformed name-status output")
25            }
26        }
27    }
28}
29
30impl Error for NameStatusParseError {}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct NameStatusZEntry<'a> {
34    pub status_raw: &'a [u8],
35    pub path: &'a [u8],
36    pub old_path: Option<&'a [u8]>,
37}
38
39pub fn parse_name_status_z(buf: &[u8]) -> Result<Vec<NameStatusZEntry<'_>>, NameStatusParseError> {
40    let parts: Vec<&[u8]> = buf
41        .split(|b| *b == 0)
42        .filter(|part| !part.is_empty())
43        .collect();
44    let mut out: Vec<NameStatusZEntry<'_>> = Vec::new();
45    let mut i = 0;
46
47    while i < parts.len() {
48        let status_raw = parts[i];
49        i += 1;
50
51        if matches!(status_raw.first(), Some(b'R' | b'C')) {
52            let old = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
53            let new = *parts
54                .get(i + 1)
55                .ok_or(NameStatusParseError::MalformedOutput)?;
56            i += 2;
57            out.push(NameStatusZEntry {
58                status_raw,
59                path: new,
60                old_path: Some(old),
61            });
62        } else {
63            let file = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
64            i += 1;
65            out.push(NameStatusZEntry {
66                status_raw,
67                path: file,
68                old_path: None,
69            });
70        }
71    }
72
73    Ok(out)
74}
75
76pub fn is_lockfile_path(path: &str) -> bool {
77    let name = Path::new(path)
78        .file_name()
79        .and_then(|segment| segment.to_str())
80        .unwrap_or("");
81    matches!(
82        name,
83        "yarn.lock"
84            | "package-lock.json"
85            | "pnpm-lock.yaml"
86            | "bun.lockb"
87            | "bun.lock"
88            | "npm-shrinkwrap.json"
89    )
90}
91
92pub fn trim_trailing_newlines(input: &str) -> String {
93    input.trim_end_matches(['\n', '\r']).to_string()
94}
95
96pub fn staged_name_only() -> io::Result<String> {
97    staged_name_only_inner(None)
98}
99
100pub fn staged_name_only_in(cwd: &Path) -> io::Result<String> {
101    staged_name_only_inner(Some(cwd))
102}
103
104pub fn suggested_scope_from_staged_paths(staged: &str) -> String {
105    let mut top: BTreeSet<String> = BTreeSet::new();
106    for line in staged.lines() {
107        let file = line.trim();
108        if file.is_empty() {
109            continue;
110        }
111        if let Some((first, _rest)) = file.split_once('/') {
112            top.insert(first.to_string());
113        } else {
114            top.insert(String::new());
115        }
116    }
117
118    if top.len() == 1 {
119        return top.iter().next().cloned().unwrap_or_default();
120    }
121
122    if top.len() == 2 && top.contains("") {
123        for part in top {
124            if !part.is_empty() {
125                return part;
126            }
127        }
128    }
129
130    String::new()
131}
132
133pub fn run_output(args: &[&str]) -> io::Result<Output> {
134    run_output_inner(None, args, &[])
135}
136
137pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
138    run_output_inner(Some(cwd), args, &[])
139}
140
141pub fn run_output_with_env(
142    args: &[&str],
143    env: &[process::ProcessEnvPair<'_>],
144) -> io::Result<Output> {
145    run_output_inner(None, args, env)
146}
147
148pub fn run_output_in_with_env(
149    cwd: &Path,
150    args: &[&str],
151    env: &[process::ProcessEnvPair<'_>],
152) -> io::Result<Output> {
153    run_output_inner(Some(cwd), args, env)
154}
155
156pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
157    run_status_quiet_inner(None, args, &[])
158}
159
160pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
161    run_status_quiet_inner(Some(cwd), args, &[])
162}
163
164pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
165    run_status_inherit_inner(None, args, &[])
166}
167
168pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
169    run_status_inherit_inner(Some(cwd), args, &[])
170}
171
172pub fn run_status_inherit_with_env(
173    args: &[&str],
174    env: &[process::ProcessEnvPair<'_>],
175) -> io::Result<ExitStatus> {
176    run_status_inherit_inner(None, args, env)
177}
178
179pub fn run_status_inherit_in_with_env(
180    cwd: &Path,
181    args: &[&str],
182    env: &[process::ProcessEnvPair<'_>],
183) -> io::Result<ExitStatus> {
184    run_status_inherit_inner(Some(cwd), args, env)
185}
186
187pub fn is_git_available() -> bool {
188    run_status_quiet(&["--version"])
189        .map(|status| status.success())
190        .unwrap_or(false)
191}
192
193pub fn require_repo() -> Result<(), GitContextError> {
194    require_context(None, &["rev-parse", "--git-dir"])
195}
196
197pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
198    require_context(Some(cwd), &["rev-parse", "--git-dir"])
199}
200
201pub fn require_work_tree() -> Result<(), GitContextError> {
202    require_context(None, &["rev-parse", "--is-inside-work-tree"])
203}
204
205pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
206    require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
207}
208
209pub fn is_inside_work_tree() -> io::Result<bool> {
210    Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
211}
212
213pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
214    Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
215}
216
217pub fn has_staged_changes() -> io::Result<bool> {
218    let status = run_status_quiet(&["diff", "--cached", "--quiet", "--"])?;
219    Ok(has_staged_changes_from_status(status))
220}
221
222pub fn has_staged_changes_in(cwd: &Path) -> io::Result<bool> {
223    let status = run_status_quiet_in(cwd, &["diff", "--cached", "--quiet", "--"])?;
224    Ok(has_staged_changes_from_status(status))
225}
226
227pub fn is_git_repo() -> io::Result<bool> {
228    Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
229}
230
231pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
232    Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
233}
234
235pub fn repo_root() -> io::Result<Option<PathBuf>> {
236    let output = run_output(&["rev-parse", "--show-toplevel"])?;
237    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
238}
239
240pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
241    let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
242    Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
243}
244
245pub fn repo_root_or_cwd() -> PathBuf {
246    repo_root()
247        .ok()
248        .flatten()
249        .or_else(|| std::env::current_dir().ok())
250        .unwrap_or_else(|| PathBuf::from("."))
251}
252
253pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
254    let output = run_output(&rev_parse_args(args))?;
255    Ok(trimmed_stdout_if_success(&output))
256}
257
258pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
259    let output = run_output_in(cwd, &rev_parse_args(args))?;
260    Ok(trimmed_stdout_if_success(&output))
261}
262
263fn run_output_inner(
264    cwd: Option<&Path>,
265    args: &[&str],
266    env: &[process::ProcessEnvPair<'_>],
267) -> io::Result<Output> {
268    process::run_output_with("git", args, cwd, env).map(|output| output.into_std_output())
269}
270
271fn run_status_quiet_inner(
272    cwd: Option<&Path>,
273    args: &[&str],
274    env: &[process::ProcessEnvPair<'_>],
275) -> io::Result<ExitStatus> {
276    process::run_status_quiet_with("git", args, cwd, env)
277}
278
279fn run_status_inherit_inner(
280    cwd: Option<&Path>,
281    args: &[&str],
282    env: &[process::ProcessEnvPair<'_>],
283) -> io::Result<ExitStatus> {
284    process::run_status_inherit_with("git", args, cwd, env)
285}
286
287fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
288    if !is_git_available() {
289        return Err(GitContextError::GitNotFound);
290    }
291
292    let in_context = match cwd {
293        Some(cwd) => run_status_quiet_in(cwd, probe_args),
294        None => run_status_quiet(probe_args),
295    }
296    .map(|status| status.success())
297    .unwrap_or(false);
298
299    if in_context {
300        Ok(())
301    } else {
302        Err(GitContextError::NotRepository)
303    }
304}
305
306fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
307    let mut full = Vec::with_capacity(args.len() + 1);
308    full.push("rev-parse");
309    full.extend_from_slice(args);
310    full
311}
312
313fn staged_name_only_inner(cwd: Option<&Path>) -> io::Result<String> {
314    let args = [
315        "-c",
316        "core.quotepath=false",
317        "diff",
318        "--cached",
319        "--name-only",
320        "--diff-filter=ACMRTUXBD",
321    ];
322    let output = match cwd {
323        Some(cwd) => run_output_in(cwd, &args)?,
324        None => run_output(&args)?,
325    };
326    Ok(String::from_utf8_lossy(&output.stdout).to_string())
327}
328
329fn has_staged_changes_from_status(status: ExitStatus) -> bool {
330    match status.code() {
331        Some(0) => false,
332        Some(1) => true,
333        _ => !status.success(),
334    }
335}
336
337fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
338    if !output.status.success() {
339        return None;
340    }
341
342    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
343    if trimmed.is_empty() {
344        None
345    } else {
346        Some(trimmed)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
354    use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
355    use pretty_assertions::assert_eq;
356    use tempfile::TempDir;
357
358    #[test]
359    fn run_output_in_preserves_nonzero_status() {
360        let repo = init_repo_with(InitRepoOptions::new());
361
362        let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
363            .expect("run output in repo");
364
365        assert!(!output.status.success());
366        assert!(!output.stderr.is_empty());
367    }
368
369    #[test]
370    fn run_status_quiet_in_returns_success_and_failure_statuses() {
371        let repo = init_repo_with(InitRepoOptions::new());
372
373        let ok =
374            run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
375        let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
376            .expect("status failure");
377
378        assert!(ok.success());
379        assert!(!bad.success());
380    }
381
382    #[test]
383    fn run_output_with_env_passes_environment_variables_to_git() {
384        let output = run_output_with_env(
385            &["config", "--get", "nils.test-env"],
386            &[
387                ("GIT_CONFIG_COUNT", "1"),
388                ("GIT_CONFIG_KEY_0", "nils.test-env"),
389                ("GIT_CONFIG_VALUE_0", "ready"),
390            ],
391        )
392        .expect("run git output with env");
393
394        assert!(output.status.success());
395        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ready");
396    }
397
398    #[test]
399    fn run_status_inherit_in_with_env_applies_cwd_and_environment() {
400        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
401        let status = run_status_inherit_in_with_env(
402            repo.path(),
403            &["config", "--get", "nils.test-status"],
404            &[
405                ("GIT_CONFIG_COUNT", "1"),
406                ("GIT_CONFIG_KEY_0", "nils.test-status"),
407                ("GIT_CONFIG_VALUE_0", "ok"),
408            ],
409        )
410        .expect("run git status in with env");
411
412        assert!(status.success());
413    }
414
415    #[test]
416    fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
417        let repo = init_repo_with(InitRepoOptions::new());
418        let outside = TempDir::new().expect("tempdir");
419
420        assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
421        assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
422        assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
423        assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
424    }
425
426    #[test]
427    fn repo_root_in_returns_root_or_none() {
428        let repo = init_repo_with(InitRepoOptions::new());
429        let outside = TempDir::new().expect("tempdir");
430        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
431            .trim()
432            .to_string();
433
434        assert_eq!(
435            repo_root_in(repo.path()).expect("repo_root_in repo"),
436            Some(expected_root.into())
437        );
438        assert_eq!(
439            repo_root_in(outside.path()).expect("repo_root_in outside"),
440            None
441        );
442    }
443
444    #[test]
445    fn rev_parse_in_returns_value_or_none() {
446        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
447        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
448            .trim()
449            .to_string();
450
451        assert_eq!(
452            rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
453            Some(head)
454        );
455        assert_eq!(
456            rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
457                .expect("rev_parse missing ref"),
458            None
459        );
460    }
461
462    #[test]
463    fn cwd_wrappers_delegate_to_in_variants() {
464        let lock = GlobalStateLock::new();
465        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
466        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
467        let head = run_git(repo.path(), &["rev-parse", "HEAD"])
468            .trim()
469            .to_string();
470        let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
471            .trim()
472            .to_string();
473
474        assert!(is_git_repo().expect("is_git_repo"));
475        assert!(is_inside_work_tree().expect("is_inside_work_tree"));
476        assert!(!has_staged_changes().expect("has_staged_changes"));
477        assert_eq!(require_repo(), Ok(()));
478        assert_eq!(require_work_tree(), Ok(()));
479        assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
480        assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
481    }
482
483    #[test]
484    fn has_staged_changes_in_reports_index_state() {
485        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
486
487        assert!(!has_staged_changes_in(repo.path()).expect("no staged changes"));
488
489        std::fs::write(repo.path().join("a.txt"), "hello\n").expect("write staged file");
490        run_git(repo.path(), &["add", "a.txt"]);
491
492        assert!(has_staged_changes_in(repo.path()).expect("staged changes present"));
493    }
494
495    #[test]
496    fn repo_root_or_cwd_prefers_repo_root_when_available() {
497        let lock = GlobalStateLock::new();
498        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
499        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
500        let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
501            .trim()
502            .to_string();
503
504        assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
505    }
506
507    #[test]
508    fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
509        let lock = GlobalStateLock::new();
510        let outside = TempDir::new().expect("tempdir");
511        let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
512
513        let resolved = repo_root_or_cwd()
514            .canonicalize()
515            .expect("canonicalize resolved path");
516        let expected = outside
517            .path()
518            .canonicalize()
519            .expect("canonicalize expected path");
520
521        assert_eq!(resolved, expected);
522    }
523
524    #[test]
525    fn require_work_tree_in_reports_missing_git_or_repo_state() {
526        let lock = GlobalStateLock::new();
527        let outside = TempDir::new().expect("tempdir");
528        let empty = TempDir::new().expect("tempdir");
529        let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
530
531        assert_eq!(
532            require_work_tree_in(outside.path()),
533            Err(GitContextError::GitNotFound)
534        );
535    }
536
537    #[test]
538    fn require_repo_and_work_tree_in_report_context_readiness() {
539        let repo = init_repo_with(InitRepoOptions::new());
540        let outside = TempDir::new().expect("tempdir");
541
542        assert_eq!(require_repo_in(repo.path()), Ok(()));
543        assert_eq!(require_work_tree_in(repo.path()), Ok(()));
544        assert_eq!(
545            require_repo_in(outside.path()),
546            Err(GitContextError::NotRepository)
547        );
548        assert_eq!(
549            require_work_tree_in(outside.path()),
550            Err(GitContextError::NotRepository)
551        );
552    }
553
554    #[test]
555    fn parse_name_status_z_handles_rename_copy_and_modify() {
556        let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
557        let entries = parse_name_status_z(bytes).expect("parse name-status");
558
559        assert_eq!(entries.len(), 3);
560        assert_eq!(entries[0].status_raw, b"R100");
561        assert_eq!(entries[0].path, b"new.txt");
562        assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
563        assert_eq!(entries[1].status_raw, b"C90");
564        assert_eq!(entries[1].path, b"dst.rs");
565        assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
566        assert_eq!(entries[2].status_raw, b"M");
567        assert_eq!(entries[2].path, b"file.txt");
568        assert_eq!(entries[2].old_path, None);
569    }
570
571    #[test]
572    fn parse_name_status_z_errors_on_malformed_output() {
573        let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
574        assert_eq!(err, NameStatusParseError::MalformedOutput);
575        assert_eq!(err.to_string(), "error: malformed name-status output");
576    }
577
578    #[test]
579    fn is_lockfile_path_matches_known_package_manager_lockfiles() {
580        for path in [
581            "yarn.lock",
582            "frontend/package-lock.json",
583            "subdir/pnpm-lock.yaml",
584            "bun.lockb",
585            "bun.lock",
586            "npm-shrinkwrap.json",
587        ] {
588            assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
589        }
590
591        assert!(!is_lockfile_path("Cargo.lock"));
592        assert!(!is_lockfile_path("package-lock.json.bak"));
593    }
594
595    #[test]
596    fn trim_trailing_newlines_drops_lf_and_crlf_suffixes() {
597        assert_eq!(trim_trailing_newlines("value\n"), "value");
598        assert_eq!(trim_trailing_newlines("value\r\n"), "value");
599        assert_eq!(trim_trailing_newlines("value"), "value");
600    }
601
602    #[test]
603    fn suggested_scope_from_staged_paths_matches_single_top_level_dir() {
604        let staged = "src/main.rs\nsrc/lib.rs\n";
605        assert_eq!(suggested_scope_from_staged_paths(staged), "src");
606    }
607
608    #[test]
609    fn suggested_scope_from_staged_paths_ignores_root_file_when_single_dir_exists() {
610        let staged = "README.md\nsrc/main.rs\n";
611        assert_eq!(suggested_scope_from_staged_paths(staged), "src");
612    }
613
614    #[test]
615    fn suggested_scope_from_staged_paths_returns_empty_when_multiple_dirs_exist() {
616        let staged = "src/main.rs\ncrates/a.rs\n";
617        assert_eq!(suggested_scope_from_staged_paths(staged), "");
618    }
619
620    #[test]
621    fn staged_name_only_in_lists_cached_paths() {
622        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
623        std::fs::write(repo.path().join("src.txt"), "hi\n").expect("write file");
624        run_git(repo.path(), &["add", "src.txt"]);
625
626        let staged = staged_name_only_in(repo.path()).expect("staged names");
627        assert!(staged.contains("src.txt"));
628    }
629
630    #[test]
631    fn staged_name_only_wrapper_uses_current_working_repo() {
632        let lock = GlobalStateLock::new();
633        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
634        std::fs::write(repo.path().join("docs.md"), "hello\n").expect("write file");
635        run_git(repo.path(), &["add", "docs.md"]);
636        let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
637
638        let staged = staged_name_only().expect("staged names");
639        assert!(staged.contains("docs.md"));
640    }
641}