Skip to main content

nils_common/
git.rs

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
96/// Return the substring after the last `@`, or the input unchanged when no
97/// `@` is present. Used by git remote URL parsers to drop the `userinfo@`
98/// prefix from a host segment (or a host+path string, since `/` cannot appear
99/// inside userinfo, splitting at the last `@` is safe for both shapes).
100pub fn strip_userinfo(host: &str) -> &str {
101    host.rsplit_once('@').map(|(_, tail)| tail).unwrap_or(host)
102}
103
104/// Host + path split out of a parsed git remote URL.
105///
106/// `host` carries the bare hostname (port and userinfo removed). `path` carries
107/// the URL path with leading/trailing slashes and a single optional trailing
108/// `.git` removed; callers split it into owner/repo or group/project segments.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct GitRemoteUrl {
111    pub host: String,
112    pub path: String,
113}
114
115/// Parse a git remote URL into a `host`/`path` pair, accepting the four shapes
116/// git itself accepts:
117///
118/// - `git@<host>:<path>` (SCP-style; `<path>` carries the slashes)
119/// - `ssh://[userinfo@]<host>[:<port>]/<path>`
120/// - `https://[userinfo@]<host>[:<port>]/<path>`
121/// - `http://[userinfo@]<host>[:<port>]/<path>`
122///
123/// Userinfo (`user@`, `user:pass@`, …) is stripped, ports are stripped from
124/// `host`, surrounding slashes and a single trailing `.git` are trimmed from
125/// `path`. Returns `None` for unknown schemes, empty hosts, or empty paths.
126pub 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    // SCP-style: [user@]host:path (host never contains `/`)
133    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    // ssh://[userinfo@]host[:port]/path
143    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    // https:// / http:// [userinfo@]host[:port]/path
154    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}