Skip to main content

git_cli/
commit_shared.rs

1#![allow(dead_code)]
2
3use anyhow::{Context, Result, anyhow};
4use nils_common::git as common_git;
5use std::process::Output;
6
7pub(crate) fn trim_trailing_newlines(input: &str) -> String {
8    input.trim_end_matches(['\n', '\r']).to_string()
9}
10
11pub(crate) fn git_output(args: &[&str]) -> Result<Output> {
12    let output = run_git_output(args).with_context(|| format!("spawn git {:?}", args))?;
13    if !output.status.success() {
14        return Err(anyhow!(
15            "git {:?} failed: {}{}",
16            args,
17            String::from_utf8_lossy(&output.stderr),
18            String::from_utf8_lossy(&output.stdout),
19        ));
20    }
21    Ok(output)
22}
23
24pub(crate) fn git_output_optional(args: &[&str]) -> Option<Output> {
25    let output = run_git_output(args).ok()?;
26    if !output.status.success() {
27        return None;
28    }
29    Some(output)
30}
31
32pub(crate) fn git_status_success(args: &[&str]) -> bool {
33    common_git::run_status_quiet(args)
34        .map(|status| status.success())
35        .unwrap_or(false)
36}
37
38pub(crate) fn git_status_code(args: &[&str]) -> Option<i32> {
39    common_git::run_status_quiet(args)
40        .ok()
41        .map(|status| status.code().unwrap_or(1))
42}
43
44pub(crate) fn git_stdout_trimmed(args: &[&str]) -> Result<String> {
45    let output = git_output(args)?;
46    Ok(trim_trailing_newlines(&String::from_utf8_lossy(
47        &output.stdout,
48    )))
49}
50
51pub(crate) fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
52    let output = git_output_optional(args)?;
53    let out = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout));
54    if out.is_empty() { None } else { Some(out) }
55}
56
57fn run_git_output(args: &[&str]) -> std::io::Result<Output> {
58    common_git::run_output(args)
59}
60
61#[derive(Debug, Clone)]
62pub(crate) struct NameStatusEntry {
63    pub status_raw: String,
64    pub path: String,
65    pub old_path: Option<String>,
66}
67
68pub(crate) fn parse_name_status_z(bytes: &[u8]) -> Result<Vec<NameStatusEntry>> {
69    let parsed = common_git::parse_name_status_z(bytes).map_err(|err| anyhow!("{err}"))?;
70    Ok(parsed
71        .into_iter()
72        .map(|entry| NameStatusEntry {
73            status_raw: String::from_utf8_lossy(entry.status_raw).to_string(),
74            path: String::from_utf8_lossy(entry.path).to_string(),
75            old_path: entry
76                .old_path
77                .map(|old_path| String::from_utf8_lossy(old_path).to_string()),
78        })
79        .collect())
80}
81
82#[derive(Debug, Clone, Copy)]
83pub(crate) struct DiffNumstat {
84    pub added: Option<i64>,
85    pub deleted: Option<i64>,
86    pub binary: bool,
87}
88
89pub(crate) fn diff_numstat(path: &str) -> Result<DiffNumstat> {
90    let output = git_stdout_trimmed(&[
91        "-c",
92        "core.quotepath=false",
93        "diff",
94        "--cached",
95        "--numstat",
96        "--",
97        path,
98    ])?;
99
100    let line = output.lines().next().unwrap_or("");
101    if line.trim().is_empty() {
102        return Ok(DiffNumstat {
103            added: None,
104            deleted: None,
105            binary: false,
106        });
107    }
108
109    let mut parts = line.split('\t');
110    let added = parts.next().unwrap_or("");
111    let deleted = parts.next().unwrap_or("");
112
113    if added == "-" || deleted == "-" {
114        return Ok(DiffNumstat {
115            added: None,
116            deleted: None,
117            binary: true,
118        });
119    }
120
121    let added_num = added.parse::<i64>().ok();
122    let deleted_num = deleted.parse::<i64>().ok();
123
124    Ok(DiffNumstat {
125        added: added_num,
126        deleted: deleted_num,
127        binary: false,
128    })
129}
130
131pub(crate) fn is_lockfile(path: &str) -> bool {
132    common_git::is_lockfile_path(path)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use nils_test_support::git::{InitRepoOptions, commit_file, git, init_repo_with};
139    use nils_test_support::{CwdGuard, GlobalStateLock};
140    use pretty_assertions::assert_eq;
141    use std::fs;
142
143    #[test]
144    fn parse_name_status_z_handles_rename_and_copy() {
145        let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
146        let entries = parse_name_status_z(bytes).expect("parse name-status");
147
148        assert_eq!(entries.len(), 3);
149        assert_eq!(entries[0].status_raw, "R100");
150        assert_eq!(entries[0].path, "new.txt");
151        assert_eq!(entries[0].old_path.as_deref(), Some("old.txt"));
152        assert_eq!(entries[1].status_raw, "C90");
153        assert_eq!(entries[1].path, "dst.rs");
154        assert_eq!(entries[1].old_path.as_deref(), Some("src.rs"));
155        assert_eq!(entries[2].status_raw, "M");
156        assert_eq!(entries[2].path, "file.txt");
157        assert_eq!(entries[2].old_path, None);
158    }
159
160    #[test]
161    fn parse_name_status_z_errors_on_malformed_input() {
162        let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse failure");
163        assert!(
164            err.to_string().contains("malformed name-status output"),
165            "unexpected error: {err}"
166        );
167    }
168
169    #[test]
170    fn diff_numstat_reports_counts_for_text_changes() {
171        let lock = GlobalStateLock::new();
172        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
173        commit_file(repo.path(), "file.txt", "one\n", "add file");
174        fs::write(repo.path().join("file.txt"), "one\ntwo\nthree\n").expect("write file");
175        git(repo.path(), &["add", "file.txt"]);
176
177        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
178        let diff = diff_numstat("file.txt").expect("diff numstat");
179
180        assert_eq!(diff.added, Some(2));
181        assert_eq!(diff.deleted, Some(0));
182        assert!(!diff.binary);
183    }
184
185    #[test]
186    fn diff_numstat_reports_binary_for_binary_file() {
187        let lock = GlobalStateLock::new();
188        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
189        fs::write(repo.path().join("bin.dat"), b"\x00\x01binary\x00").expect("write bin");
190        git(repo.path(), &["add", "bin.dat"]);
191
192        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
193        let diff = diff_numstat("bin.dat").expect("diff numstat");
194
195        assert!(diff.binary);
196        assert_eq!(diff.added, None);
197        assert_eq!(diff.deleted, None);
198    }
199
200    #[test]
201    fn diff_numstat_reports_none_when_no_changes() {
202        let lock = GlobalStateLock::new();
203        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
204        commit_file(repo.path(), "file.txt", "one\n", "add file");
205
206        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
207        let diff = diff_numstat("file.txt").expect("diff numstat");
208
209        assert_eq!(diff.added, None);
210        assert_eq!(diff.deleted, None);
211        assert!(!diff.binary);
212    }
213
214    #[test]
215    fn is_lockfile_detects_known_names() {
216        for name in [
217            "yarn.lock",
218            "package-lock.json",
219            "pnpm-lock.yaml",
220            "bun.lockb",
221            "bun.lock",
222            "npm-shrinkwrap.json",
223            "path/to/yarn.lock",
224        ] {
225            assert!(is_lockfile(name), "expected {name} to be a lockfile");
226        }
227
228        assert!(!is_lockfile("Cargo.lock"));
229        assert!(!is_lockfile("README.md"));
230    }
231}