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}