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 parts: Vec<&[u8]> = bytes.split(|b| *b == 0).filter(|p| !p.is_empty()).collect();
70    let mut out: Vec<NameStatusEntry> = Vec::new();
71    let mut i = 0;
72
73    while i < parts.len() {
74        let status_raw = String::from_utf8_lossy(parts[i]).to_string();
75        i += 1;
76
77        if status_raw.starts_with('R') || status_raw.starts_with('C') {
78            let old = parts
79                .get(i)
80                .ok_or_else(|| anyhow!("error: malformed name-status output"))?;
81            let new = parts
82                .get(i + 1)
83                .ok_or_else(|| anyhow!("error: malformed name-status output"))?;
84            i += 2;
85            out.push(NameStatusEntry {
86                status_raw,
87                path: String::from_utf8_lossy(new).to_string(),
88                old_path: Some(String::from_utf8_lossy(old).to_string()),
89            });
90        } else {
91            let file = parts
92                .get(i)
93                .ok_or_else(|| anyhow!("error: malformed name-status output"))?;
94            i += 1;
95            out.push(NameStatusEntry {
96                status_raw,
97                path: String::from_utf8_lossy(file).to_string(),
98                old_path: None,
99            });
100        }
101    }
102
103    Ok(out)
104}
105
106#[derive(Debug, Clone, Copy)]
107pub(crate) struct DiffNumstat {
108    pub added: Option<i64>,
109    pub deleted: Option<i64>,
110    pub binary: bool,
111}
112
113pub(crate) fn diff_numstat(path: &str) -> Result<DiffNumstat> {
114    let output = git_stdout_trimmed(&[
115        "-c",
116        "core.quotepath=false",
117        "diff",
118        "--cached",
119        "--numstat",
120        "--",
121        path,
122    ])?;
123
124    let line = output.lines().next().unwrap_or("");
125    if line.trim().is_empty() {
126        return Ok(DiffNumstat {
127            added: None,
128            deleted: None,
129            binary: false,
130        });
131    }
132
133    let mut parts = line.split('\t');
134    let added = parts.next().unwrap_or("");
135    let deleted = parts.next().unwrap_or("");
136
137    if added == "-" || deleted == "-" {
138        return Ok(DiffNumstat {
139            added: None,
140            deleted: None,
141            binary: true,
142        });
143    }
144
145    let added_num = added.parse::<i64>().ok();
146    let deleted_num = deleted.parse::<i64>().ok();
147
148    Ok(DiffNumstat {
149        added: added_num,
150        deleted: deleted_num,
151        binary: false,
152    })
153}
154
155pub(crate) fn is_lockfile(path: &str) -> bool {
156    let name = std::path::Path::new(path)
157        .file_name()
158        .and_then(|s| s.to_str())
159        .unwrap_or("");
160    matches!(
161        name,
162        "yarn.lock"
163            | "package-lock.json"
164            | "pnpm-lock.yaml"
165            | "bun.lockb"
166            | "bun.lock"
167            | "npm-shrinkwrap.json"
168    )
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use nils_test_support::git::{InitRepoOptions, commit_file, git, init_repo_with};
175    use nils_test_support::{CwdGuard, GlobalStateLock};
176    use pretty_assertions::assert_eq;
177    use std::fs;
178
179    #[test]
180    fn parse_name_status_z_handles_rename_and_copy() {
181        let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
182        let entries = parse_name_status_z(bytes).expect("parse name-status");
183
184        assert_eq!(entries.len(), 3);
185        assert_eq!(entries[0].status_raw, "R100");
186        assert_eq!(entries[0].path, "new.txt");
187        assert_eq!(entries[0].old_path.as_deref(), Some("old.txt"));
188        assert_eq!(entries[1].status_raw, "C90");
189        assert_eq!(entries[1].path, "dst.rs");
190        assert_eq!(entries[1].old_path.as_deref(), Some("src.rs"));
191        assert_eq!(entries[2].status_raw, "M");
192        assert_eq!(entries[2].path, "file.txt");
193        assert_eq!(entries[2].old_path, None);
194    }
195
196    #[test]
197    fn parse_name_status_z_errors_on_malformed_input() {
198        let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse failure");
199        assert!(
200            err.to_string().contains("malformed name-status output"),
201            "unexpected error: {err}"
202        );
203    }
204
205    #[test]
206    fn diff_numstat_reports_counts_for_text_changes() {
207        let lock = GlobalStateLock::new();
208        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
209        commit_file(repo.path(), "file.txt", "one\n", "add file");
210        fs::write(repo.path().join("file.txt"), "one\ntwo\nthree\n").expect("write file");
211        git(repo.path(), &["add", "file.txt"]);
212
213        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
214        let diff = diff_numstat("file.txt").expect("diff numstat");
215
216        assert_eq!(diff.added, Some(2));
217        assert_eq!(diff.deleted, Some(0));
218        assert!(!diff.binary);
219    }
220
221    #[test]
222    fn diff_numstat_reports_binary_for_binary_file() {
223        let lock = GlobalStateLock::new();
224        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
225        fs::write(repo.path().join("bin.dat"), b"\x00\x01binary\x00").expect("write bin");
226        git(repo.path(), &["add", "bin.dat"]);
227
228        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
229        let diff = diff_numstat("bin.dat").expect("diff numstat");
230
231        assert!(diff.binary);
232        assert_eq!(diff.added, None);
233        assert_eq!(diff.deleted, None);
234    }
235
236    #[test]
237    fn diff_numstat_reports_none_when_no_changes() {
238        let lock = GlobalStateLock::new();
239        let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
240        commit_file(repo.path(), "file.txt", "one\n", "add file");
241
242        let _cwd = CwdGuard::set(&lock, repo.path()).expect("cwd");
243        let diff = diff_numstat("file.txt").expect("diff numstat");
244
245        assert_eq!(diff.added, None);
246        assert_eq!(diff.deleted, None);
247        assert!(!diff.binary);
248    }
249
250    #[test]
251    fn is_lockfile_detects_known_names() {
252        for name in [
253            "yarn.lock",
254            "package-lock.json",
255            "pnpm-lock.yaml",
256            "bun.lockb",
257            "bun.lock",
258            "npm-shrinkwrap.json",
259            "path/to/yarn.lock",
260        ] {
261            assert!(is_lockfile(name), "expected {name} to be a lockfile");
262        }
263
264        assert!(!is_lockfile("Cargo.lock"));
265        assert!(!is_lockfile("README.md"));
266    }
267}