1use crate::process;
2use std::collections::BTreeSet;
3use std::error::Error;
4use std::fmt;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{ExitStatus, Output};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GitContextError {
11 GitNotFound,
12 NotRepository,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum NameStatusParseError {
17 MalformedOutput,
18}
19
20impl fmt::Display for NameStatusParseError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 NameStatusParseError::MalformedOutput => {
24 write!(f, "error: malformed name-status output")
25 }
26 }
27 }
28}
29
30impl Error for NameStatusParseError {}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct NameStatusZEntry<'a> {
34 pub status_raw: &'a [u8],
35 pub path: &'a [u8],
36 pub old_path: Option<&'a [u8]>,
37}
38
39pub fn parse_name_status_z(buf: &[u8]) -> Result<Vec<NameStatusZEntry<'_>>, NameStatusParseError> {
40 let parts: Vec<&[u8]> = buf
41 .split(|b| *b == 0)
42 .filter(|part| !part.is_empty())
43 .collect();
44 let mut out: Vec<NameStatusZEntry<'_>> = Vec::new();
45 let mut i = 0;
46
47 while i < parts.len() {
48 let status_raw = parts[i];
49 i += 1;
50
51 if matches!(status_raw.first(), Some(b'R' | b'C')) {
52 let old = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
53 let new = *parts
54 .get(i + 1)
55 .ok_or(NameStatusParseError::MalformedOutput)?;
56 i += 2;
57 out.push(NameStatusZEntry {
58 status_raw,
59 path: new,
60 old_path: Some(old),
61 });
62 } else {
63 let file = *parts.get(i).ok_or(NameStatusParseError::MalformedOutput)?;
64 i += 1;
65 out.push(NameStatusZEntry {
66 status_raw,
67 path: file,
68 old_path: None,
69 });
70 }
71 }
72
73 Ok(out)
74}
75
76pub fn is_lockfile_path(path: &str) -> bool {
77 let name = Path::new(path)
78 .file_name()
79 .and_then(|segment| segment.to_str())
80 .unwrap_or("");
81 matches!(
82 name,
83 "yarn.lock"
84 | "package-lock.json"
85 | "pnpm-lock.yaml"
86 | "bun.lockb"
87 | "bun.lock"
88 | "npm-shrinkwrap.json"
89 )
90}
91
92pub fn trim_trailing_newlines(input: &str) -> String {
93 input.trim_end_matches(['\n', '\r']).to_string()
94}
95
96pub fn staged_name_only() -> io::Result<String> {
97 staged_name_only_inner(None)
98}
99
100pub fn staged_name_only_in(cwd: &Path) -> io::Result<String> {
101 staged_name_only_inner(Some(cwd))
102}
103
104pub fn suggested_scope_from_staged_paths(staged: &str) -> String {
105 let mut top: BTreeSet<String> = BTreeSet::new();
106 for line in staged.lines() {
107 let file = line.trim();
108 if file.is_empty() {
109 continue;
110 }
111 if let Some((first, _rest)) = file.split_once('/') {
112 top.insert(first.to_string());
113 } else {
114 top.insert(String::new());
115 }
116 }
117
118 if top.len() == 1 {
119 return top.iter().next().cloned().unwrap_or_default();
120 }
121
122 if top.len() == 2 && top.contains("") {
123 for part in top {
124 if !part.is_empty() {
125 return part;
126 }
127 }
128 }
129
130 String::new()
131}
132
133pub fn run_output(args: &[&str]) -> io::Result<Output> {
134 run_output_inner(None, args, &[])
135}
136
137pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
138 run_output_inner(Some(cwd), args, &[])
139}
140
141pub fn run_output_with_env(
142 args: &[&str],
143 env: &[process::ProcessEnvPair<'_>],
144) -> io::Result<Output> {
145 run_output_inner(None, args, env)
146}
147
148pub fn run_output_in_with_env(
149 cwd: &Path,
150 args: &[&str],
151 env: &[process::ProcessEnvPair<'_>],
152) -> io::Result<Output> {
153 run_output_inner(Some(cwd), args, env)
154}
155
156pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
157 run_status_quiet_inner(None, args, &[])
158}
159
160pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
161 run_status_quiet_inner(Some(cwd), args, &[])
162}
163
164pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
165 run_status_inherit_inner(None, args, &[])
166}
167
168pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
169 run_status_inherit_inner(Some(cwd), args, &[])
170}
171
172pub fn run_status_inherit_with_env(
173 args: &[&str],
174 env: &[process::ProcessEnvPair<'_>],
175) -> io::Result<ExitStatus> {
176 run_status_inherit_inner(None, args, env)
177}
178
179pub fn run_status_inherit_in_with_env(
180 cwd: &Path,
181 args: &[&str],
182 env: &[process::ProcessEnvPair<'_>],
183) -> io::Result<ExitStatus> {
184 run_status_inherit_inner(Some(cwd), args, env)
185}
186
187pub fn is_git_available() -> bool {
188 run_status_quiet(&["--version"])
189 .map(|status| status.success())
190 .unwrap_or(false)
191}
192
193pub fn require_repo() -> Result<(), GitContextError> {
194 require_context(None, &["rev-parse", "--git-dir"])
195}
196
197pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
198 require_context(Some(cwd), &["rev-parse", "--git-dir"])
199}
200
201pub fn require_work_tree() -> Result<(), GitContextError> {
202 require_context(None, &["rev-parse", "--is-inside-work-tree"])
203}
204
205pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
206 require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
207}
208
209pub fn is_inside_work_tree() -> io::Result<bool> {
210 Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
211}
212
213pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
214 Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
215}
216
217pub fn has_staged_changes() -> io::Result<bool> {
218 let status = run_status_quiet(&["diff", "--cached", "--quiet", "--"])?;
219 Ok(has_staged_changes_from_status(status))
220}
221
222pub fn has_staged_changes_in(cwd: &Path) -> io::Result<bool> {
223 let status = run_status_quiet_in(cwd, &["diff", "--cached", "--quiet", "--"])?;
224 Ok(has_staged_changes_from_status(status))
225}
226
227pub fn is_git_repo() -> io::Result<bool> {
228 Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
229}
230
231pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
232 Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
233}
234
235pub fn repo_root() -> io::Result<Option<PathBuf>> {
236 let output = run_output(&["rev-parse", "--show-toplevel"])?;
237 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
238}
239
240pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
241 let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
242 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
243}
244
245pub fn repo_root_or_cwd() -> PathBuf {
246 repo_root()
247 .ok()
248 .flatten()
249 .or_else(|| std::env::current_dir().ok())
250 .unwrap_or_else(|| PathBuf::from("."))
251}
252
253pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
254 let output = run_output(&rev_parse_args(args))?;
255 Ok(trimmed_stdout_if_success(&output))
256}
257
258pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
259 let output = run_output_in(cwd, &rev_parse_args(args))?;
260 Ok(trimmed_stdout_if_success(&output))
261}
262
263fn run_output_inner(
264 cwd: Option<&Path>,
265 args: &[&str],
266 env: &[process::ProcessEnvPair<'_>],
267) -> io::Result<Output> {
268 process::run_output_with("git", args, cwd, env).map(|output| output.into_std_output())
269}
270
271fn run_status_quiet_inner(
272 cwd: Option<&Path>,
273 args: &[&str],
274 env: &[process::ProcessEnvPair<'_>],
275) -> io::Result<ExitStatus> {
276 process::run_status_quiet_with("git", args, cwd, env)
277}
278
279fn run_status_inherit_inner(
280 cwd: Option<&Path>,
281 args: &[&str],
282 env: &[process::ProcessEnvPair<'_>],
283) -> io::Result<ExitStatus> {
284 process::run_status_inherit_with("git", args, cwd, env)
285}
286
287fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
288 if !is_git_available() {
289 return Err(GitContextError::GitNotFound);
290 }
291
292 let in_context = match cwd {
293 Some(cwd) => run_status_quiet_in(cwd, probe_args),
294 None => run_status_quiet(probe_args),
295 }
296 .map(|status| status.success())
297 .unwrap_or(false);
298
299 if in_context {
300 Ok(())
301 } else {
302 Err(GitContextError::NotRepository)
303 }
304}
305
306fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
307 let mut full = Vec::with_capacity(args.len() + 1);
308 full.push("rev-parse");
309 full.extend_from_slice(args);
310 full
311}
312
313fn staged_name_only_inner(cwd: Option<&Path>) -> io::Result<String> {
314 let args = [
315 "-c",
316 "core.quotepath=false",
317 "diff",
318 "--cached",
319 "--name-only",
320 "--diff-filter=ACMRTUXBD",
321 ];
322 let output = match cwd {
323 Some(cwd) => run_output_in(cwd, &args)?,
324 None => run_output(&args)?,
325 };
326 Ok(String::from_utf8_lossy(&output.stdout).to_string())
327}
328
329fn has_staged_changes_from_status(status: ExitStatus) -> bool {
330 match status.code() {
331 Some(0) => false,
332 Some(1) => true,
333 _ => !status.success(),
334 }
335}
336
337fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
338 if !output.status.success() {
339 return None;
340 }
341
342 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
343 if trimmed.is_empty() {
344 None
345 } else {
346 Some(trimmed)
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
354 use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
355 use pretty_assertions::assert_eq;
356 use tempfile::TempDir;
357
358 #[test]
359 fn run_output_in_preserves_nonzero_status() {
360 let repo = init_repo_with(InitRepoOptions::new());
361
362 let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
363 .expect("run output in repo");
364
365 assert!(!output.status.success());
366 assert!(!output.stderr.is_empty());
367 }
368
369 #[test]
370 fn run_status_quiet_in_returns_success_and_failure_statuses() {
371 let repo = init_repo_with(InitRepoOptions::new());
372
373 let ok =
374 run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
375 let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
376 .expect("status failure");
377
378 assert!(ok.success());
379 assert!(!bad.success());
380 }
381
382 #[test]
383 fn run_output_with_env_passes_environment_variables_to_git() {
384 let output = run_output_with_env(
385 &["config", "--get", "nils.test-env"],
386 &[
387 ("GIT_CONFIG_COUNT", "1"),
388 ("GIT_CONFIG_KEY_0", "nils.test-env"),
389 ("GIT_CONFIG_VALUE_0", "ready"),
390 ],
391 )
392 .expect("run git output with env");
393
394 assert!(output.status.success());
395 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ready");
396 }
397
398 #[test]
399 fn run_status_inherit_in_with_env_applies_cwd_and_environment() {
400 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
401 let status = run_status_inherit_in_with_env(
402 repo.path(),
403 &["config", "--get", "nils.test-status"],
404 &[
405 ("GIT_CONFIG_COUNT", "1"),
406 ("GIT_CONFIG_KEY_0", "nils.test-status"),
407 ("GIT_CONFIG_VALUE_0", "ok"),
408 ],
409 )
410 .expect("run git status in with env");
411
412 assert!(status.success());
413 }
414
415 #[test]
416 fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
417 let repo = init_repo_with(InitRepoOptions::new());
418 let outside = TempDir::new().expect("tempdir");
419
420 assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
421 assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
422 assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
423 assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
424 }
425
426 #[test]
427 fn repo_root_in_returns_root_or_none() {
428 let repo = init_repo_with(InitRepoOptions::new());
429 let outside = TempDir::new().expect("tempdir");
430 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
431 .trim()
432 .to_string();
433
434 assert_eq!(
435 repo_root_in(repo.path()).expect("repo_root_in repo"),
436 Some(expected_root.into())
437 );
438 assert_eq!(
439 repo_root_in(outside.path()).expect("repo_root_in outside"),
440 None
441 );
442 }
443
444 #[test]
445 fn rev_parse_in_returns_value_or_none() {
446 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
447 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
448 .trim()
449 .to_string();
450
451 assert_eq!(
452 rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
453 Some(head)
454 );
455 assert_eq!(
456 rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
457 .expect("rev_parse missing ref"),
458 None
459 );
460 }
461
462 #[test]
463 fn cwd_wrappers_delegate_to_in_variants() {
464 let lock = GlobalStateLock::new();
465 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
466 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
467 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
468 .trim()
469 .to_string();
470 let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
471 .trim()
472 .to_string();
473
474 assert!(is_git_repo().expect("is_git_repo"));
475 assert!(is_inside_work_tree().expect("is_inside_work_tree"));
476 assert!(!has_staged_changes().expect("has_staged_changes"));
477 assert_eq!(require_repo(), Ok(()));
478 assert_eq!(require_work_tree(), Ok(()));
479 assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
480 assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
481 }
482
483 #[test]
484 fn has_staged_changes_in_reports_index_state() {
485 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
486
487 assert!(!has_staged_changes_in(repo.path()).expect("no staged changes"));
488
489 std::fs::write(repo.path().join("a.txt"), "hello\n").expect("write staged file");
490 run_git(repo.path(), &["add", "a.txt"]);
491
492 assert!(has_staged_changes_in(repo.path()).expect("staged changes present"));
493 }
494
495 #[test]
496 fn repo_root_or_cwd_prefers_repo_root_when_available() {
497 let lock = GlobalStateLock::new();
498 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
499 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
500 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
501 .trim()
502 .to_string();
503
504 assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
505 }
506
507 #[test]
508 fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
509 let lock = GlobalStateLock::new();
510 let outside = TempDir::new().expect("tempdir");
511 let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
512
513 let resolved = repo_root_or_cwd()
514 .canonicalize()
515 .expect("canonicalize resolved path");
516 let expected = outside
517 .path()
518 .canonicalize()
519 .expect("canonicalize expected path");
520
521 assert_eq!(resolved, expected);
522 }
523
524 #[test]
525 fn require_work_tree_in_reports_missing_git_or_repo_state() {
526 let lock = GlobalStateLock::new();
527 let outside = TempDir::new().expect("tempdir");
528 let empty = TempDir::new().expect("tempdir");
529 let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
530
531 assert_eq!(
532 require_work_tree_in(outside.path()),
533 Err(GitContextError::GitNotFound)
534 );
535 }
536
537 #[test]
538 fn require_repo_and_work_tree_in_report_context_readiness() {
539 let repo = init_repo_with(InitRepoOptions::new());
540 let outside = TempDir::new().expect("tempdir");
541
542 assert_eq!(require_repo_in(repo.path()), Ok(()));
543 assert_eq!(require_work_tree_in(repo.path()), Ok(()));
544 assert_eq!(
545 require_repo_in(outside.path()),
546 Err(GitContextError::NotRepository)
547 );
548 assert_eq!(
549 require_work_tree_in(outside.path()),
550 Err(GitContextError::NotRepository)
551 );
552 }
553
554 #[test]
555 fn parse_name_status_z_handles_rename_copy_and_modify() {
556 let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
557 let entries = parse_name_status_z(bytes).expect("parse name-status");
558
559 assert_eq!(entries.len(), 3);
560 assert_eq!(entries[0].status_raw, b"R100");
561 assert_eq!(entries[0].path, b"new.txt");
562 assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
563 assert_eq!(entries[1].status_raw, b"C90");
564 assert_eq!(entries[1].path, b"dst.rs");
565 assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
566 assert_eq!(entries[2].status_raw, b"M");
567 assert_eq!(entries[2].path, b"file.txt");
568 assert_eq!(entries[2].old_path, None);
569 }
570
571 #[test]
572 fn parse_name_status_z_errors_on_malformed_output() {
573 let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
574 assert_eq!(err, NameStatusParseError::MalformedOutput);
575 assert_eq!(err.to_string(), "error: malformed name-status output");
576 }
577
578 #[test]
579 fn is_lockfile_path_matches_known_package_manager_lockfiles() {
580 for path in [
581 "yarn.lock",
582 "frontend/package-lock.json",
583 "subdir/pnpm-lock.yaml",
584 "bun.lockb",
585 "bun.lock",
586 "npm-shrinkwrap.json",
587 ] {
588 assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
589 }
590
591 assert!(!is_lockfile_path("Cargo.lock"));
592 assert!(!is_lockfile_path("package-lock.json.bak"));
593 }
594
595 #[test]
596 fn trim_trailing_newlines_drops_lf_and_crlf_suffixes() {
597 assert_eq!(trim_trailing_newlines("value\n"), "value");
598 assert_eq!(trim_trailing_newlines("value\r\n"), "value");
599 assert_eq!(trim_trailing_newlines("value"), "value");
600 }
601
602 #[test]
603 fn suggested_scope_from_staged_paths_matches_single_top_level_dir() {
604 let staged = "src/main.rs\nsrc/lib.rs\n";
605 assert_eq!(suggested_scope_from_staged_paths(staged), "src");
606 }
607
608 #[test]
609 fn suggested_scope_from_staged_paths_ignores_root_file_when_single_dir_exists() {
610 let staged = "README.md\nsrc/main.rs\n";
611 assert_eq!(suggested_scope_from_staged_paths(staged), "src");
612 }
613
614 #[test]
615 fn suggested_scope_from_staged_paths_returns_empty_when_multiple_dirs_exist() {
616 let staged = "src/main.rs\ncrates/a.rs\n";
617 assert_eq!(suggested_scope_from_staged_paths(staged), "");
618 }
619
620 #[test]
621 fn staged_name_only_in_lists_cached_paths() {
622 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
623 std::fs::write(repo.path().join("src.txt"), "hi\n").expect("write file");
624 run_git(repo.path(), &["add", "src.txt"]);
625
626 let staged = staged_name_only_in(repo.path()).expect("staged names");
627 assert!(staged.contains("src.txt"));
628 }
629
630 #[test]
631 fn staged_name_only_wrapper_uses_current_working_repo() {
632 let lock = GlobalStateLock::new();
633 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
634 std::fs::write(repo.path().join("docs.md"), "hello\n").expect("write file");
635 run_git(repo.path(), &["add", "docs.md"]);
636 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
637
638 let staged = staged_name_only().expect("staged names");
639 assert!(staged.contains("docs.md"));
640 }
641}