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 strip_userinfo(host: &str) -> &str {
101 host.rsplit_once('@').map(|(_, tail)| tail).unwrap_or(host)
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct GitRemoteUrl {
111 pub host: String,
112 pub path: String,
113}
114
115pub fn parse_git_remote_url(url: &str) -> Option<GitRemoteUrl> {
127 let trimmed = url.trim().trim_end_matches('/');
128 if trimmed.is_empty() {
129 return None;
130 }
131
132 if !trimmed.contains("://")
134 && let Some((host_with_user, path)) = trimmed.split_once(':')
135 && !host_with_user.contains('/')
136 && !path.contains("://")
137 {
138 let host = strip_userinfo(host_with_user);
139 return finalize(host, path);
140 }
141
142 if let Some(rest) = trimmed.strip_prefix("ssh://") {
144 let after_user = strip_userinfo(rest);
145 let (host_port, path) = after_user.split_once('/')?;
146 let host = host_port
147 .split_once(':')
148 .map(|(h, _)| h)
149 .unwrap_or(host_port);
150 return finalize(host, path);
151 }
152
153 for prefix in ["https://", "http://"] {
155 if let Some(rest) = trimmed.strip_prefix(prefix) {
156 let (host_with_user, path) = rest.split_once('/')?;
157 let host_no_user = strip_userinfo(host_with_user);
158 let host = host_no_user
159 .split_once(':')
160 .map(|(h, _)| h)
161 .unwrap_or(host_no_user);
162 return finalize(host, path);
163 }
164 }
165
166 None
167}
168
169fn finalize(host: &str, path: &str) -> Option<GitRemoteUrl> {
170 let host = host.trim();
171 let path = path.trim_matches('/').trim_end_matches(".git");
172 if host.is_empty() || path.is_empty() {
173 return None;
174 }
175 Some(GitRemoteUrl {
176 host: host.to_string(),
177 path: path.to_string(),
178 })
179}
180
181pub fn staged_name_only() -> io::Result<String> {
182 staged_name_only_inner(None)
183}
184
185pub fn staged_name_only_in(cwd: &Path) -> io::Result<String> {
186 staged_name_only_inner(Some(cwd))
187}
188
189pub fn suggested_scope_from_staged_paths(staged: &str) -> String {
190 let mut top: BTreeSet<String> = BTreeSet::new();
191 for line in staged.lines() {
192 let file = line.trim();
193 if file.is_empty() {
194 continue;
195 }
196 if let Some((first, _rest)) = file.split_once('/') {
197 top.insert(first.to_string());
198 } else {
199 top.insert(String::new());
200 }
201 }
202
203 if top.len() == 1 {
204 return top.iter().next().cloned().unwrap_or_default();
205 }
206
207 if top.len() == 2 && top.contains("") {
208 for part in top {
209 if !part.is_empty() {
210 return part;
211 }
212 }
213 }
214
215 String::new()
216}
217
218pub fn run_output(args: &[&str]) -> io::Result<Output> {
219 run_output_inner(None, args, &[])
220}
221
222pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
223 run_output_inner(Some(cwd), args, &[])
224}
225
226pub fn run_output_with_env(
227 args: &[&str],
228 env: &[process::ProcessEnvPair<'_>],
229) -> io::Result<Output> {
230 run_output_inner(None, args, env)
231}
232
233pub fn run_output_in_with_env(
234 cwd: &Path,
235 args: &[&str],
236 env: &[process::ProcessEnvPair<'_>],
237) -> io::Result<Output> {
238 run_output_inner(Some(cwd), args, env)
239}
240
241pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
242 run_status_quiet_inner(None, args, &[])
243}
244
245pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
246 run_status_quiet_inner(Some(cwd), args, &[])
247}
248
249pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
250 run_status_inherit_inner(None, args, &[])
251}
252
253pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
254 run_status_inherit_inner(Some(cwd), args, &[])
255}
256
257pub fn run_status_inherit_with_env(
258 args: &[&str],
259 env: &[process::ProcessEnvPair<'_>],
260) -> io::Result<ExitStatus> {
261 run_status_inherit_inner(None, args, env)
262}
263
264pub fn run_status_inherit_in_with_env(
265 cwd: &Path,
266 args: &[&str],
267 env: &[process::ProcessEnvPair<'_>],
268) -> io::Result<ExitStatus> {
269 run_status_inherit_inner(Some(cwd), args, env)
270}
271
272pub fn is_git_available() -> bool {
273 run_status_quiet(&["--version"])
274 .map(|status| status.success())
275 .unwrap_or(false)
276}
277
278pub fn require_repo() -> Result<(), GitContextError> {
279 require_context(None, &["rev-parse", "--git-dir"])
280}
281
282pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
283 require_context(Some(cwd), &["rev-parse", "--git-dir"])
284}
285
286pub fn require_work_tree() -> Result<(), GitContextError> {
287 require_context(None, &["rev-parse", "--is-inside-work-tree"])
288}
289
290pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
291 require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
292}
293
294pub fn is_inside_work_tree() -> io::Result<bool> {
295 Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
296}
297
298pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
299 Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
300}
301
302pub fn has_staged_changes() -> io::Result<bool> {
303 let status = run_status_quiet(&["diff", "--cached", "--quiet", "--"])?;
304 Ok(has_staged_changes_from_status(status))
305}
306
307pub fn has_staged_changes_in(cwd: &Path) -> io::Result<bool> {
308 let status = run_status_quiet_in(cwd, &["diff", "--cached", "--quiet", "--"])?;
309 Ok(has_staged_changes_from_status(status))
310}
311
312pub fn is_git_repo() -> io::Result<bool> {
313 Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
314}
315
316pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
317 Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
318}
319
320pub fn repo_root() -> io::Result<Option<PathBuf>> {
321 let output = run_output(&["rev-parse", "--show-toplevel"])?;
322 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
323}
324
325pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
326 let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
327 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
328}
329
330pub fn repo_root_or_cwd() -> PathBuf {
331 repo_root()
332 .ok()
333 .flatten()
334 .or_else(|| std::env::current_dir().ok())
335 .unwrap_or_else(|| PathBuf::from("."))
336}
337
338pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
339 let output = run_output(&rev_parse_args(args))?;
340 Ok(trimmed_stdout_if_success(&output))
341}
342
343pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
344 let output = run_output_in(cwd, &rev_parse_args(args))?;
345 Ok(trimmed_stdout_if_success(&output))
346}
347
348fn run_output_inner(
349 cwd: Option<&Path>,
350 args: &[&str],
351 env: &[process::ProcessEnvPair<'_>],
352) -> io::Result<Output> {
353 process::run_output_with("git", args, cwd, env).map(|output| output.into_std_output())
354}
355
356fn run_status_quiet_inner(
357 cwd: Option<&Path>,
358 args: &[&str],
359 env: &[process::ProcessEnvPair<'_>],
360) -> io::Result<ExitStatus> {
361 process::run_status_quiet_with("git", args, cwd, env)
362}
363
364fn run_status_inherit_inner(
365 cwd: Option<&Path>,
366 args: &[&str],
367 env: &[process::ProcessEnvPair<'_>],
368) -> io::Result<ExitStatus> {
369 process::run_status_inherit_with("git", args, cwd, env)
370}
371
372fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
373 if !is_git_available() {
374 return Err(GitContextError::GitNotFound);
375 }
376
377 let in_context = match cwd {
378 Some(cwd) => run_status_quiet_in(cwd, probe_args),
379 None => run_status_quiet(probe_args),
380 }
381 .map(|status| status.success())
382 .unwrap_or(false);
383
384 if in_context {
385 Ok(())
386 } else {
387 Err(GitContextError::NotRepository)
388 }
389}
390
391fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
392 let mut full = Vec::with_capacity(args.len() + 1);
393 full.push("rev-parse");
394 full.extend_from_slice(args);
395 full
396}
397
398fn staged_name_only_inner(cwd: Option<&Path>) -> io::Result<String> {
399 let args = [
400 "-c",
401 "core.quotepath=false",
402 "diff",
403 "--cached",
404 "--name-only",
405 "--diff-filter=ACMRTUXBD",
406 ];
407 let output = match cwd {
408 Some(cwd) => run_output_in(cwd, &args)?,
409 None => run_output(&args)?,
410 };
411 Ok(String::from_utf8_lossy(&output.stdout).to_string())
412}
413
414fn has_staged_changes_from_status(status: ExitStatus) -> bool {
415 match status.code() {
416 Some(0) => false,
417 Some(1) => true,
418 _ => !status.success(),
419 }
420}
421
422fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
423 if !output.status.success() {
424 return None;
425 }
426
427 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
428 if trimmed.is_empty() {
429 None
430 } else {
431 Some(trimmed)
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
439 use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
440 use pretty_assertions::assert_eq;
441 use tempfile::TempDir;
442
443 #[test]
444 fn run_output_in_preserves_nonzero_status() {
445 let repo = init_repo_with(InitRepoOptions::new());
446
447 let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
448 .expect("run output in repo");
449
450 assert!(!output.status.success());
451 assert!(!output.stderr.is_empty());
452 }
453
454 #[test]
455 fn run_status_quiet_in_returns_success_and_failure_statuses() {
456 let repo = init_repo_with(InitRepoOptions::new());
457
458 let ok =
459 run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
460 let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
461 .expect("status failure");
462
463 assert!(ok.success());
464 assert!(!bad.success());
465 }
466
467 #[test]
468 fn run_output_with_env_passes_environment_variables_to_git() {
469 let output = run_output_with_env(
470 &["config", "--get", "nils.test-env"],
471 &[
472 ("GIT_CONFIG_COUNT", "1"),
473 ("GIT_CONFIG_KEY_0", "nils.test-env"),
474 ("GIT_CONFIG_VALUE_0", "ready"),
475 ],
476 )
477 .expect("run git output with env");
478
479 assert!(output.status.success());
480 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ready");
481 }
482
483 #[test]
484 fn run_status_inherit_in_with_env_applies_cwd_and_environment() {
485 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
486 let status = run_status_inherit_in_with_env(
487 repo.path(),
488 &["config", "--get", "nils.test-status"],
489 &[
490 ("GIT_CONFIG_COUNT", "1"),
491 ("GIT_CONFIG_KEY_0", "nils.test-status"),
492 ("GIT_CONFIG_VALUE_0", "ok"),
493 ],
494 )
495 .expect("run git status in with env");
496
497 assert!(status.success());
498 }
499
500 #[test]
501 fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
502 let repo = init_repo_with(InitRepoOptions::new());
503 let outside = TempDir::new().expect("tempdir");
504
505 assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
506 assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
507 assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
508 assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
509 }
510
511 #[test]
512 fn repo_root_in_returns_root_or_none() {
513 let repo = init_repo_with(InitRepoOptions::new());
514 let outside = TempDir::new().expect("tempdir");
515 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
516 .trim()
517 .to_string();
518
519 assert_eq!(
520 repo_root_in(repo.path()).expect("repo_root_in repo"),
521 Some(expected_root.into())
522 );
523 assert_eq!(
524 repo_root_in(outside.path()).expect("repo_root_in outside"),
525 None
526 );
527 }
528
529 #[test]
530 fn rev_parse_in_returns_value_or_none() {
531 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
532 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
533 .trim()
534 .to_string();
535
536 assert_eq!(
537 rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
538 Some(head)
539 );
540 assert_eq!(
541 rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
542 .expect("rev_parse missing ref"),
543 None
544 );
545 }
546
547 #[test]
548 fn cwd_wrappers_delegate_to_in_variants() {
549 let lock = GlobalStateLock::new();
550 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
551 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
552 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
553 .trim()
554 .to_string();
555 let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
556 .trim()
557 .to_string();
558
559 assert!(is_git_repo().expect("is_git_repo"));
560 assert!(is_inside_work_tree().expect("is_inside_work_tree"));
561 assert!(!has_staged_changes().expect("has_staged_changes"));
562 assert_eq!(require_repo(), Ok(()));
563 assert_eq!(require_work_tree(), Ok(()));
564 assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
565 assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
566 }
567
568 #[test]
569 fn has_staged_changes_in_reports_index_state() {
570 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
571
572 assert!(!has_staged_changes_in(repo.path()).expect("no staged changes"));
573
574 std::fs::write(repo.path().join("a.txt"), "hello\n").expect("write staged file");
575 run_git(repo.path(), &["add", "a.txt"]);
576
577 assert!(has_staged_changes_in(repo.path()).expect("staged changes present"));
578 }
579
580 #[test]
581 fn repo_root_or_cwd_prefers_repo_root_when_available() {
582 let lock = GlobalStateLock::new();
583 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
584 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
585 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
586 .trim()
587 .to_string();
588
589 assert_eq!(repo_root_or_cwd(), PathBuf::from(expected_root));
590 }
591
592 #[test]
593 fn repo_root_or_cwd_falls_back_to_current_dir_outside_repo() {
594 let lock = GlobalStateLock::new();
595 let outside = TempDir::new().expect("tempdir");
596 let _cwd = CwdGuard::set(&lock, outside.path()).expect("set cwd");
597
598 let resolved = repo_root_or_cwd()
599 .canonicalize()
600 .expect("canonicalize resolved path");
601 let expected = outside
602 .path()
603 .canonicalize()
604 .expect("canonicalize expected path");
605
606 assert_eq!(resolved, expected);
607 }
608
609 #[test]
610 fn require_work_tree_in_reports_missing_git_or_repo_state() {
611 let lock = GlobalStateLock::new();
612 let outside = TempDir::new().expect("tempdir");
613 let empty = TempDir::new().expect("tempdir");
614 let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
615
616 assert_eq!(
617 require_work_tree_in(outside.path()),
618 Err(GitContextError::GitNotFound)
619 );
620 }
621
622 #[test]
623 fn require_repo_and_work_tree_in_report_context_readiness() {
624 let repo = init_repo_with(InitRepoOptions::new());
625 let outside = TempDir::new().expect("tempdir");
626
627 assert_eq!(require_repo_in(repo.path()), Ok(()));
628 assert_eq!(require_work_tree_in(repo.path()), Ok(()));
629 assert_eq!(
630 require_repo_in(outside.path()),
631 Err(GitContextError::NotRepository)
632 );
633 assert_eq!(
634 require_work_tree_in(outside.path()),
635 Err(GitContextError::NotRepository)
636 );
637 }
638
639 #[test]
640 fn parse_name_status_z_handles_rename_copy_and_modify() {
641 let bytes = b"R100\0old.txt\0new.txt\0C90\0src.rs\0dst.rs\0M\0file.txt\0";
642 let entries = parse_name_status_z(bytes).expect("parse name-status");
643
644 assert_eq!(entries.len(), 3);
645 assert_eq!(entries[0].status_raw, b"R100");
646 assert_eq!(entries[0].path, b"new.txt");
647 assert_eq!(entries[0].old_path, Some(&b"old.txt"[..]));
648 assert_eq!(entries[1].status_raw, b"C90");
649 assert_eq!(entries[1].path, b"dst.rs");
650 assert_eq!(entries[1].old_path, Some(&b"src.rs"[..]));
651 assert_eq!(entries[2].status_raw, b"M");
652 assert_eq!(entries[2].path, b"file.txt");
653 assert_eq!(entries[2].old_path, None);
654 }
655
656 #[test]
657 fn parse_name_status_z_errors_on_malformed_output() {
658 let err = parse_name_status_z(b"R100\0old.txt\0").expect_err("expected parse error");
659 assert_eq!(err, NameStatusParseError::MalformedOutput);
660 assert_eq!(err.to_string(), "error: malformed name-status output");
661 }
662
663 #[test]
664 fn is_lockfile_path_matches_known_package_manager_lockfiles() {
665 for path in [
666 "yarn.lock",
667 "frontend/package-lock.json",
668 "subdir/pnpm-lock.yaml",
669 "bun.lockb",
670 "bun.lock",
671 "npm-shrinkwrap.json",
672 ] {
673 assert!(is_lockfile_path(path), "expected {path} to be a lockfile");
674 }
675
676 assert!(!is_lockfile_path("Cargo.lock"));
677 assert!(!is_lockfile_path("package-lock.json.bak"));
678 }
679
680 #[test]
681 fn trim_trailing_newlines_drops_lf_and_crlf_suffixes() {
682 assert_eq!(trim_trailing_newlines("value\n"), "value");
683 assert_eq!(trim_trailing_newlines("value\r\n"), "value");
684 assert_eq!(trim_trailing_newlines("value"), "value");
685 }
686
687 #[test]
688 fn suggested_scope_from_staged_paths_matches_single_top_level_dir() {
689 let staged = "src/main.rs\nsrc/lib.rs\n";
690 assert_eq!(suggested_scope_from_staged_paths(staged), "src");
691 }
692
693 #[test]
694 fn suggested_scope_from_staged_paths_ignores_root_file_when_single_dir_exists() {
695 let staged = "README.md\nsrc/main.rs\n";
696 assert_eq!(suggested_scope_from_staged_paths(staged), "src");
697 }
698
699 #[test]
700 fn suggested_scope_from_staged_paths_returns_empty_when_multiple_dirs_exist() {
701 let staged = "src/main.rs\ncrates/a.rs\n";
702 assert_eq!(suggested_scope_from_staged_paths(staged), "");
703 }
704
705 #[test]
706 fn staged_name_only_in_lists_cached_paths() {
707 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
708 std::fs::write(repo.path().join("src.txt"), "hi\n").expect("write file");
709 run_git(repo.path(), &["add", "src.txt"]);
710
711 let staged = staged_name_only_in(repo.path()).expect("staged names");
712 assert!(staged.contains("src.txt"));
713 }
714
715 #[test]
716 fn staged_name_only_wrapper_uses_current_working_repo() {
717 let lock = GlobalStateLock::new();
718 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
719 std::fs::write(repo.path().join("docs.md"), "hello\n").expect("write file");
720 run_git(repo.path(), &["add", "docs.md"]);
721 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
722
723 let staged = staged_name_only().expect("staged names");
724 assert!(staged.contains("docs.md"));
725 }
726
727 #[test]
728 fn strip_userinfo_passthrough_when_no_at() {
729 assert_eq!(strip_userinfo("github.com"), "github.com");
730 assert_eq!(
731 strip_userinfo("gitlab.example.com:2222"),
732 "gitlab.example.com:2222"
733 );
734 assert_eq!(strip_userinfo(""), "");
735 }
736
737 #[test]
738 fn strip_userinfo_drops_user_only_prefix() {
739 assert_eq!(strip_userinfo("git@github.com"), "github.com");
740 assert_eq!(strip_userinfo("x-access-token@gitlab.com"), "gitlab.com");
741 }
742
743 #[test]
744 fn strip_userinfo_drops_user_password_prefix() {
745 assert_eq!(strip_userinfo("user:pass@github.com"), "github.com");
746 assert_eq!(strip_userinfo("user:p@ss@gitlab.com"), "gitlab.com");
747 }
748
749 #[test]
750 fn parse_git_remote_url_handles_scp_form() {
751 let r = parse_git_remote_url("git@github.com:sympoies/nils-cli.git").expect("scp");
752 assert_eq!(r.host, "github.com");
753 assert_eq!(r.path, "sympoies/nils-cli");
754 }
755
756 #[test]
757 fn parse_git_remote_url_handles_scp_form_nested_gitlab_group() {
758 let r = parse_git_remote_url("git@gitlab.example.com:acme/platform/backend/ingest.git")
759 .expect("scp nested");
760 assert_eq!(r.host, "gitlab.example.com");
761 assert_eq!(r.path, "acme/platform/backend/ingest");
762 }
763
764 #[test]
765 fn parse_git_remote_url_handles_ssh_with_userinfo_and_port() {
766 let r = parse_git_remote_url("ssh://deploy@gitlab.example.com:2222/group/proj.git")
767 .expect("ssh");
768 assert_eq!(r.host, "gitlab.example.com");
769 assert_eq!(r.path, "group/proj");
770 }
771
772 #[test]
773 fn parse_git_remote_url_handles_https_with_basic_auth() {
774 let r = parse_git_remote_url("https://user:pass@github.com/sympoies/nils-cli.git")
775 .expect("https");
776 assert_eq!(r.host, "github.com");
777 assert_eq!(r.path, "sympoies/nils-cli");
778 }
779
780 #[test]
781 fn parse_git_remote_url_handles_https_with_port() {
782 let r = parse_git_remote_url("https://gitlab.example.com:8443/group/proj").expect("port");
783 assert_eq!(r.host, "gitlab.example.com");
784 assert_eq!(r.path, "group/proj");
785 }
786
787 #[test]
788 fn parse_git_remote_url_handles_http() {
789 let r = parse_git_remote_url("http://gitlab.example.com/group/proj.git").expect("http");
790 assert_eq!(r.host, "gitlab.example.com");
791 assert_eq!(r.path, "group/proj");
792 }
793
794 #[test]
795 fn parse_git_remote_url_rejects_unknown_schemes_and_empty() {
796 assert!(parse_git_remote_url("").is_none());
797 assert!(parse_git_remote_url("file:///tmp/x.git").is_none());
798 assert!(parse_git_remote_url("ftp://host/path").is_none());
799 }
800
801 #[test]
802 fn parse_git_remote_url_rejects_empty_host_or_path() {
803 assert!(parse_git_remote_url("https://user:pass@/owner/repo").is_none());
804 assert!(parse_git_remote_url("ssh://deploy@/owner/repo").is_none());
805 assert!(parse_git_remote_url("https://github.com/").is_none());
806 }
807}