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}