1use std::error::Error;
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus, Output, Stdio};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitContextError {
9 GitNotFound,
10 NotRepository,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NameStatusParseError {
15 MalformedOutput,
16}
17
18impl fmt::Display for NameStatusParseError {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 NameStatusParseError::MalformedOutput => {
22 write!(f, "error: malformed name-status output")
23 }
24 }
25 }
26}
27
28impl Error for NameStatusParseError {}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct NameStatusZEntry<'a> {
32 pub status_raw: &'a [u8],
33 pub path: &'a [u8],
34 pub old_path: Option<&'a [u8]>,
35}
36
37pub fn parse_name_status_z(buf: &[u8]) -> Result<Vec<NameStatusZEntry<'_>>, NameStatusParseError> {
38 let parts: Vec<&[u8]> = buf
39 .split(|b| *b == 0)
40 .filter(|part| !part.is_empty())
41 .collect();
42 let mut out: Vec<NameStatusZEntry<'_>> = Vec::new();
43 let mut i = 0;
44
45 while i < parts.len() {
46 let status_raw = parts[i];
47 i += 1;
48
49 if matches!(status_raw.first(), Some(b'R' | b'C')) {
50 let old = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
51 let new = *parts
52 .get(i + 1)
53 .ok_or(NameStatusParseError::MalformedOutput)?;
54 i += 2;
55 out.push(NameStatusZEntry {
56 status_raw,
57 path: new,
58 old_path: Some(old),
59 });
60 } else {
61 let file = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
62 i += 1;
63 out.push(NameStatusZEntry {
64 status_raw,
65 path: file,
66 old_path: None,
67 });
68 }
69 }
70
71 Ok(out)
72}
73
74pub fn is_lockfile_path(path: &str) -> bool {
75 let name = Path::new(path)
76 .file_name()
77 .and_then(|segment| segment.to_str())
78 .unwrap_or("");
79 matches!(
80 name,
81 "yarn.lock"
82 | "package-lock.json"
83 | "pnpm-lock.yaml"
84 | "bun.lockb"
85 | "bun.lock"
86 | "npm-shrinkwrap.json"
87 )
88}
89
90pub fn run_output(args: &[&str]) -> io::Result<Output> {
91 run_output_inner(None, args)
92}
93
94pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
95 run_output_inner(Some(cwd), args)
96}
97
98pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
99 run_status_quiet_inner(None, args)
100}
101
102pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
103 run_status_quiet_inner(Some(cwd), args)
104}
105
106pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
107 run_status_inherit_inner(None, args)
108}
109
110pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
111 run_status_inherit_inner(Some(cwd), args)
112}
113
114pub fn is_git_available() -> bool {
115 run_status_quiet(&["--version"])
116 .map(|status| status.success())
117 .unwrap_or(false)
118}
119
120pub fn require_repo() -> Result<(), GitContextError> {
121 require_context(None, &["rev-parse", "--git-dir"])
122}
123
124pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
125 require_context(Some(cwd), &["rev-parse", "--git-dir"])
126}
127
128pub fn require_work_tree() -> Result<(), GitContextError> {
129 require_context(None, &["rev-parse", "--is-inside-work-tree"])
130}
131
132pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
133 require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
134}
135
136pub fn is_inside_work_tree() -> io::Result<bool> {
137 Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
138}
139
140pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
141 Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
142}
143
144pub fn is_git_repo() -> io::Result<bool> {
145 Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
146}
147
148pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
149 Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
150}
151
152pub fn repo_root() -> io::Result<Option<PathBuf>> {
153 let output = run_output(&["rev-parse", "--show-toplevel"])?;
154 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
155}
156
157pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
158 let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
159 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
160}
161
162pub fn repo_root_or_cwd() -> PathBuf {
163 repo_root()
164 .ok()
165 .flatten()
166 .or_else(|| std::env::current_dir().ok())
167 .unwrap_or_else(|| PathBuf::from("."))
168}
169
170pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
171 let output = run_output(&rev_parse_args(args))?;
172 Ok(trimmed_stdout_if_success(&output))
173}
174
175pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
176 let output = run_output_in(cwd, &rev_parse_args(args))?;
177 Ok(trimmed_stdout_if_success(&output))
178}
179
180fn run_output_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<Output> {
181 let mut cmd = Command::new("git");
182 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
183 if let Some(cwd) = cwd {
184 cmd.current_dir(cwd);
185 }
186 cmd.output()
187}
188
189fn run_status_quiet_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
190 let mut cmd = Command::new("git");
191 cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
192 if let Some(cwd) = cwd {
193 cmd.current_dir(cwd);
194 }
195 cmd.status()
196}
197
198fn run_status_inherit_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
199 let mut cmd = Command::new("git");
200 cmd.args(args)
201 .stdout(Stdio::inherit())
202 .stderr(Stdio::inherit());
203 if let Some(cwd) = cwd {
204 cmd.current_dir(cwd);
205 }
206 cmd.status()
207}
208
209fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
210 if !is_git_available() {
211 return Err(GitContextError::GitNotFound);
212 }
213
214 let in_context = match cwd {
215 Some(cwd) => run_status_quiet_in(cwd, probe_args),
216 None => run_status_quiet(probe_args),
217 }
218 .map(|status| status.success())
219 .unwrap_or(false);
220
221 if in_context {
222 Ok(())
223 } else {
224 Err(GitContextError::NotRepository)
225 }
226}
227
228fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
229 let mut full = Vec::with_capacity(args.len() + 1);
230 full.push("rev-parse");
231 full.extend_from_slice(args);
232 full
233}
234
235fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
236 if !output.status.success() {
237 return None;
238 }
239
240 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
241 if trimmed.is_empty() {
242 None
243 } else {
244 Some(trimmed)
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
252 use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
253 use pretty_assertions::assert_eq;
254 use tempfile::TempDir;
255
256 #[test]
257 fn run_output_in_preserves_nonzero_status() {
258 let repo = init_repo_with(InitRepoOptions::new());
259
260 let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
261 .expect("run output in repo");
262
263 assert!(!output.status.success());
264 assert!(!output.stderr.is_empty());
265 }
266
267 #[test]
268 fn run_status_quiet_in_returns_success_and_failure_statuses() {
269 let repo = init_repo_with(InitRepoOptions::new());
270
271 let ok =
272 run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
273 let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
274 .expect("status failure");
275
276 assert!(ok.success());
277 assert!(!bad.success());
278 }
279
280 #[test]
281 fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
282 let repo = init_repo_with(InitRepoOptions::new());
283 let outside = TempDir::new().expect("tempdir");
284
285 assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
286 assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
287 assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
288 assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
289 }
290
291 #[test]
292 fn repo_root_in_returns_root_or_none() {
293 let repo = init_repo_with(InitRepoOptions::new());
294 let outside = TempDir::new().expect("tempdir");
295 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
296 .trim()
297 .to_string();
298
299 assert_eq!(
300 repo_root_in(repo.path()).expect("repo_root_in repo"),
301 Some(expected_root.into())
302 );
303 assert_eq!(
304 repo_root_in(outside.path()).expect("repo_root_in outside"),
305 None
306 );
307 }
308
309 #[test]
310 fn rev_parse_in_returns_value_or_none() {
311 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
312 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
313 .trim()
314 .to_string();
315
316 assert_eq!(
317 rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
318 Some(head)
319 );
320 assert_eq!(
321 rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
322 .expect("rev_parse missing ref"),
323 None
324 );
325 }
326
327 #[test]
328 fn cwd_wrappers_delegate_to_in_variants() {
329 let lock = GlobalStateLock::new();
330 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
331 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
332 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
333 .trim()
334 .to_string();
335 let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
336 .trim()
337 .to_string();
338
339 assert!(is_git_repo().expect("is_git_repo"));
340 assert!(is_inside_work_tree().expect("is_inside_work_tree"));
341 assert_eq!(require_repo(), Ok(()));
342 assert_eq!(require_work_tree(), Ok(()));
343 assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
344 assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
345 }
346
347 #[test]
348 fn repo_root_or_cwd_prefers_repo_root_when_available() {
349 let lock = GlobalStateLock::new();
350 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
351 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
352 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
353 .trim()
354 .to_string();
355
356 assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
357 }
358
359 #[test]
360 fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
361 let lock = GlobalStateLock::new();
362 let outside = TempDir::new().expect("tempdir");
363 let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
364
365 let resolved = repo_root_or_cwd()
366 .canonicalize()
367 .expect("canonicalize resolved path");
368 let expected = outside
369 .path()
370 .canonicalize()
371 .expect("canonicalize expected path");
372
373 assert_eq!(resolved, expected);
374 }
375
376 #[test]
377 fn require_work_tree_in_reports_missing_git_or_repo_state() {
378 let lock = GlobalStateLock::new();
379 let outside = TempDir::new().expect("tempdir");
380 let empty = TempDir::new().expect("tempdir");
381 let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
382
383 assert_eq!(
384 require_work_tree_in(outside.path()),
385 Err(GitContextError::GitNotFound)
386 );
387 }
388
389 #[test]
390 fn require_repo_and_work_tree_in_report_context_readiness() {
391 let repo = init_repo_with(InitRepoOptions::new());
392 let outside = TempDir::new().expect("tempdir");
393
394 assert_eq!(require_repo_in(repo.path()), Ok(()));
395 assert_eq!(require_work_tree_in(repo.path()), Ok(()));
396 assert_eq!(
397 require_repo_in(outside.path()),
398 Err(GitContextError::NotRepository)
399 );
400 assert_eq!(
401 require_work_tree_in(outside.path()),
402 Err(GitContextError::NotRepository)
403 );
404 }
405
406 #[test]
407 fn parse_name_status_z_handles_rename_copy_and_modify() {
408 let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
409 let entries = parse_name_status_z(bytes).expect("parse name-status");
410
411 assert_eq!(entries.len(), 3);
412 assert_eq!(entries[0].status_raw, b"R100");
413 assert_eq!(entries[0].path, b"new.txt");
414 assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
415 assert_eq!(entries[1].status_raw, b"C90");
416 assert_eq!(entries[1].path, b"dst.rs");
417 assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
418 assert_eq!(entries[2].status_raw, b"M");
419 assert_eq!(entries[2].path, b"file.txt");
420 assert_eq!(entries[2].old_path, None);
421 }
422
423 #[test]
424 fn parse_name_status_z_errors_on_malformed_output() {
425 let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
426 assert_eq!(err, NameStatusParseError::MalformedOutput);
427 assert_eq!(err.to_string(), "error: malformed name-status output");
428 }
429
430 #[test]
431 fn is_lockfile_path_matches_known_package_manager_lockfiles() {
432 for path in [
433 "yarn.lock",
434 "frontend/package-lock.json",
435 "subdir/pnpm-lock.yaml",
436 "bun.lockb",
437 "bun.lock",
438 "npm-shrinkwrap.json",
439 ] {
440 assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
441 }
442
443 assert!(!is_lockfile_path("Cargo.lock"));
444 assert!(!is_lockfile_path("package-lock.json.bak"));
445 }
446}