1use std::net::ToSocketAddrs;
5use std::path::Path;
6use std::sync::OnceLock;
7
8use anyhow::{bail, Context, Result};
9
10use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
11
12fn git_host_allowlist() -> &'static [String] {
16 static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
17 ALLOW.get_or_init(|| {
18 std::env::var("SLOC_GIT_HOST_ALLOWLIST")
19 .unwrap_or_default()
20 .split(',')
21 .map(|s| s.trim().to_lowercase())
22 .filter(|s| !s.is_empty())
23 .collect()
24 })
25}
26
27fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
30 let mut cmd = std::process::Command::new("git");
31 let out = cmd
32 .args(args)
33 .current_dir(repo)
34 .output()
35 .context("failed to spawn git process")?;
36 if !out.status.success() {
37 let stderr = String::from_utf8_lossy(&out.stderr);
38 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
39 }
40 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
41}
42
43#[must_use]
52pub fn normalize_git_url(raw: &str) -> String {
53 let url = raw.trim();
54 if url.starts_with("git@") || url.starts_with("ssh://") {
55 return url.to_owned();
56 }
57 let scheme = if url.starts_with("https://") {
58 "https"
59 } else if url.starts_with("http://") {
60 "http"
61 } else {
62 return url.to_owned();
63 };
64 let authority_and_path = &url[scheme.len() + 3..];
65 let (host, path) = authority_and_path
66 .find('/')
67 .map_or((authority_and_path, "/"), |i| {
68 (&authority_and_path[..i], &authority_and_path[i..])
69 });
70 let path = path.trim_end_matches('/');
71
72 try_normalize_bitbucket_server(scheme, host, path)
73 .or_else(|| try_normalize_gitlab(scheme, host, path))
74 .or_else(|| try_normalize_github(scheme, host, path))
75 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
76 .unwrap_or_else(|| url.to_owned())
77}
78
79fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
83 let path_lower = path.to_lowercase();
84 let proj_pos = path_lower.find("/projects/")?;
85 let after = &path[proj_pos + "/projects/".len()..];
86 let parts: Vec<&str> = after.splitn(4, '/').collect();
87 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
88 return None;
89 }
90 let context = &path[..proj_pos];
91 let project = parts[0].to_lowercase();
92 let repo = parts[2].trim_end_matches(".git");
93 Some(format!(
94 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
95 ))
96}
97
98fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
101 let idx = path.find("/-/")?;
102 let repo_path = path[..idx].trim_end_matches(".git");
103 Some(format!("{scheme}://{host}{repo_path}.git"))
104}
105
106fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
109 if host != "github.com" && !host.ends_with(".github.com") {
110 return None;
111 }
112 let p = path.trim_start_matches('/');
113 let parts: Vec<&str> = p.splitn(4, '/').collect();
114 if parts.len() < 3
115 || !matches!(
116 parts[2],
117 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
118 )
119 {
120 return None;
121 }
122 let owner = parts[0];
123 let repo = parts[1].trim_end_matches(".git");
124 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
125}
126
127fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
130 if host != "bitbucket.org" {
131 return None;
132 }
133 let p = path.trim_start_matches('/');
134 let parts: Vec<&str> = p.splitn(4, '/').collect();
135 if parts.len() < 3 || parts[2] != "src" {
136 return None;
137 }
138 let ws = parts[0];
139 let repo = parts[1].trim_end_matches(".git");
140 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
141}
142
143fn validate_clone_url(url: &str) -> Result<()> {
146 let lower = url.to_lowercase();
147 let allowed = ["https://", "git://", "ssh://", "git@"];
150 if !allowed.iter().any(|p| lower.starts_with(p)) {
151 bail!(
152 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
153 permitted (got {url:?})"
154 );
155 }
156 let Some(host) = host_of_git_url(url) else {
163 return Ok(());
164 };
165 check_host_allowed(&host)?;
166 check_resolved_ips(&host, url)?;
167 Ok(())
168}
169
170fn check_host_allowed(host: &str) -> Result<()> {
174 let allow = git_host_allowlist();
180 if !allow.is_empty() && !allow.iter().any(|h| h == host) {
181 bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
182 }
183 if is_ssrf_blocked_host(host) {
184 bail!(
185 "git URL rejected: loopback, link-local, and cloud-metadata \
186 addresses are not permitted (host {host:?})"
187 );
188 }
189 Ok(())
190}
191
192fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
198 let Some(port) = port_of_git_url(url) else {
199 return Ok(());
200 };
201 let Ok(addrs) = (host, port).to_socket_addrs() else {
202 return Ok(());
203 };
204 for addr in addrs {
205 if is_ssrf_blocked_ip(addr.ip()) {
206 bail!(
207 "git URL rejected: host {host:?} resolves to a blocked \
208 address {} (loopback/link-local/cloud-metadata)",
209 addr.ip()
210 );
211 }
212 }
213 Ok(())
214}
215
216fn host_of_git_url(url: &str) -> Option<String> {
219 let u = url.trim();
220 if let Some(rest) = u.strip_prefix("git@") {
222 let host = rest.split(':').next().unwrap_or(rest);
223 return Some(host.to_lowercase());
224 }
225 let after_scheme = u.split("://").nth(1)?;
227 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
228 let authority = authority.rsplit('@').next().unwrap_or(authority);
230 let host = authority.strip_prefix('[').map_or_else(
232 || authority.split(':').next().unwrap_or(authority).to_string(),
233 |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
234 );
235 Some(host.to_lowercase())
236}
237
238fn port_of_git_url(url: &str) -> Option<u16> {
242 let u = url.trim();
243 if u.starts_with("git@") {
245 return Some(22);
246 }
247 let (scheme, after_scheme) = u.split_once("://")?;
248 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
249 let authority = authority.rsplit('@').next().unwrap_or(authority);
250 let explicit = authority.strip_prefix('[').map_or_else(
252 || {
254 authority
255 .rsplit_once(':')
256 .and_then(|(_, p)| p.parse::<u16>().ok())
257 },
258 |stripped| {
260 stripped
261 .split_once("]:")
262 .and_then(|(_, p)| p.parse::<u16>().ok())
263 },
264 );
265 explicit.or_else(|| match scheme.to_lowercase().as_str() {
266 "https" => Some(443),
267 "git" => Some(9418),
268 "ssh" => Some(22),
269 _ => None,
270 })
271}
272
273const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
275 "metadata.google.internal",
276 "metadata.internal",
277 "instance-data",
278];
279
280fn is_ssrf_blocked_host(host: &str) -> bool {
284 let h = host
285 .trim()
286 .trim_start_matches('[')
287 .trim_end_matches(']')
288 .to_lowercase();
289 if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
290 return true;
291 }
292 h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
293}
294
295fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
298 match ip {
299 std::net::IpAddr::V4(v4) => {
300 v4.is_loopback()
301 || v4.is_link_local()
302 || v4.is_unspecified()
303 || v4.is_broadcast()
304 || v4.is_multicast()
305 || v4.octets() == [100, 100, 100, 200] }
307 std::net::IpAddr::V6(v6) => {
308 v6.is_loopback()
309 || v6.is_unspecified()
310 || v6.is_multicast()
311 || (v6.segments()[0] & 0xffc0) == 0xfe80 }
313 }
314}
315
316pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
325 let normalized = normalize_git_url(url);
326 let url = normalized.as_str();
327 validate_clone_url(url)?;
328 if dest.join(".git").exists() {
331 run_git(
332 dest,
333 &[
334 "-c",
335 "http.followRedirects=false",
336 "fetch",
337 "--all",
338 "--tags",
339 "--prune",
340 ],
341 )?;
342 } else {
343 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
344 let dest_str = dest.to_str().unwrap_or(".");
345 let parent = dest.parent().unwrap_or(dest);
346 run_git(
347 parent,
348 &[
349 "-c",
350 "http.followRedirects=false",
351 "clone",
352 "--no-single-branch",
353 "--depth=50",
354 url,
355 dest_str,
356 ],
357 )?;
358 }
359 Ok(())
360}
361
362pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
367 run_git(repo, &["rev-parse", ref_name])
368}
369
370pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
377 let wt = worktree_path.to_str().unwrap_or(".");
378 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
379 Ok(())
380}
381
382pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
387 let wt = worktree_path.to_str().unwrap_or(".");
388 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
389 Ok(())
390}
391
392pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
399 Ok(RepoRefs {
400 branches: list_branches(repo)?,
401 tags: list_tags(repo)?,
402 recent_commits: list_commits(repo, "HEAD", 40)?,
403 })
404}
405
406fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
407 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
408 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
412 let refs = out
413 .lines()
414 .filter(|l| !l.trim().is_empty())
415 .map(|l| parse_ref_line(l, GitRefKind::Branch))
416 .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
418 .map(|mut r| {
419 if let Some(slash) = r.name.find('/') {
421 r.name = r.name[slash + 1..].to_owned();
422 }
423 r
424 })
425 .collect::<Vec<_>>();
426 Ok(refs)
427}
428
429fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
430 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
431 let out = run_git(
432 repo,
433 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
434 )?;
435 Ok(out
436 .lines()
437 .filter(|l| !l.trim().is_empty())
438 .map(|l| parse_ref_line(l, GitRefKind::Tag))
439 .collect())
440}
441
442fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
443 let parts: Vec<&str> = line.splitn(4, '|').collect();
444 let name = parts.first().copied().unwrap_or("").to_owned();
445 let sha = parts.get(1).copied().unwrap_or("").to_owned();
446 let date = parts.get(2).copied().and_then(parse_git_date);
447 let message = parts.get(3).map(|s| (*s).to_owned());
448 GitRef {
449 kind,
450 name,
451 sha,
452 date,
453 message,
454 }
455}
456
457pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
464 let fmt = "%H|%h|%an|%aI|%s";
465 let n = format!("-{limit}");
466 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
467 Ok(out
468 .lines()
469 .filter(|l| !l.trim().is_empty())
470 .map(parse_commit_line)
471 .collect())
472}
473
474fn parse_commit_line(line: &str) -> GitCommit {
475 let p: Vec<&str> = line.splitn(5, '|').collect();
476 let sha = p.first().copied().unwrap_or("").to_owned();
477 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
478 let author = p.get(2).copied().unwrap_or("").to_owned();
479 let date = p
480 .get(3)
481 .copied()
482 .and_then(parse_git_date)
483 .unwrap_or_default();
484 let subject = p.get(4).copied().unwrap_or("").to_owned();
485 GitCommit {
486 sha,
487 short_sha,
488 author,
489 date,
490 subject,
491 }
492}
493
494fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
495 chrono::DateTime::parse_from_rfc3339(s)
496 .ok()
497 .map(|d| d.with_timezone(&chrono::Utc))
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::GitRefKind;
504 use chrono::Timelike as _;
505
506 #[test]
509 fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
510 assert!(is_ssrf_blocked_host("localhost"));
511 assert!(is_ssrf_blocked_host("metadata.google.internal"));
512 assert!(is_ssrf_blocked_host("metadata.internal"));
513 assert!(is_ssrf_blocked_host("instance-data"));
514 assert!(is_ssrf_blocked_host(" LOCALHOST "));
516 assert!(is_ssrf_blocked_host("127.0.0.1"));
518 assert!(is_ssrf_blocked_host("[::1]"));
519 assert!(is_ssrf_blocked_host("169.254.169.254"));
520 }
521
522 #[test]
523 fn is_ssrf_blocked_host_allows_public_hosts() {
524 assert!(!is_ssrf_blocked_host("github.com"));
525 assert!(!is_ssrf_blocked_host("example.com"));
526 assert!(!is_ssrf_blocked_host("192.168.1.10"));
528 assert!(!is_ssrf_blocked_host("10.0.0.1"));
529 }
530
531 #[test]
534 fn normalize_github_tree_url() {
535 assert_eq!(
536 normalize_git_url("https://github.com/owner/repo/tree/main"),
537 "https://github.com/owner/repo.git"
538 );
539 }
540
541 #[test]
542 fn normalize_github_blob_url() {
543 assert_eq!(
544 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
545 "https://github.com/owner/repo.git"
546 );
547 }
548
549 #[test]
550 fn normalize_github_commits_url() {
551 assert_eq!(
552 normalize_git_url("https://github.com/owner/repo/commits/main"),
553 "https://github.com/owner/repo.git"
554 );
555 }
556
557 #[test]
558 fn normalize_github_releases_url() {
559 assert_eq!(
560 normalize_git_url("https://github.com/owner/repo/releases"),
561 "https://github.com/owner/repo.git"
562 );
563 }
564
565 #[test]
566 fn normalize_github_tags_url() {
567 assert_eq!(
568 normalize_git_url("https://github.com/owner/repo/tags"),
569 "https://github.com/owner/repo.git"
570 );
571 }
572
573 #[test]
574 fn normalize_github_branches_url() {
575 assert_eq!(
576 normalize_git_url("https://github.com/owner/repo/branches"),
577 "https://github.com/owner/repo.git"
578 );
579 }
580
581 #[test]
582 fn normalize_github_plain_clone_url_unchanged() {
583 let url = "https://github.com/owner/repo.git";
584 assert_eq!(normalize_git_url(url), url);
585 }
586
587 #[test]
588 fn normalize_gitlab_tree_url() {
589 assert_eq!(
590 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
591 "https://gitlab.com/group/subgroup/repo.git"
592 );
593 }
594
595 #[test]
596 fn normalize_gitlab_blob_url() {
597 assert_eq!(
598 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
599 "https://gitlab.com/org/repo.git"
600 );
601 }
602
603 #[test]
604 fn normalize_gitlab_self_hosted() {
605 assert_eq!(
606 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
607 "https://gitlab.corp.com/team/project.git"
608 );
609 }
610
611 #[test]
612 fn normalize_bitbucket_server_browse_url() {
613 assert_eq!(
614 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
615 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
616 );
617 }
618
619 #[test]
620 fn normalize_bitbucket_server_with_context() {
621 assert_eq!(
622 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
623 "https://host.com/ctx/scm/proj/repo.git"
624 );
625 }
626
627 #[test]
628 fn normalize_bitbucket_cloud_src_url() {
629 assert_eq!(
630 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
631 "https://bitbucket.org/workspace/repo.git"
632 );
633 }
634
635 #[test]
636 fn normalize_ssh_url_unchanged() {
637 let url = "git@github.com:owner/repo.git";
638 assert_eq!(normalize_git_url(url), url);
639 }
640
641 #[test]
642 fn normalize_ssh_protocol_url_unchanged() {
643 let url = "ssh://git@github.com/owner/repo.git";
644 assert_eq!(normalize_git_url(url), url);
645 }
646
647 #[test]
648 fn normalize_trims_leading_trailing_whitespace() {
649 assert_eq!(
650 normalize_git_url(" https://github.com/owner/repo/tree/main "),
651 "https://github.com/owner/repo.git"
652 );
653 }
654
655 #[test]
656 fn normalize_http_url_without_match_returned_unchanged() {
657 let url = "http://internal.corp.com/repo.git";
658 assert_eq!(normalize_git_url(url), url);
659 }
660
661 #[test]
664 fn validate_https_url_ok() {
665 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
666 }
667
668 #[test]
669 fn validate_git_protocol_url_ok() {
670 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
671 }
672
673 #[test]
674 fn validate_ssh_protocol_url_ok() {
675 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
676 }
677
678 #[test]
679 fn validate_git_at_url_ok() {
680 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
681 }
682
683 #[test]
684 fn validate_http_plain_rejected() {
685 assert!(
686 validate_clone_url("http://github.com/owner/repo.git").is_err(),
687 "plain http:// must be rejected"
688 );
689 }
690
691 #[test]
692 fn validate_link_local_169_254_rejected() {
693 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
694 }
695
696 #[test]
697 fn validate_google_metadata_endpoint_rejected() {
698 assert!(
699 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
700 );
701 }
702
703 #[test]
704 fn validate_alibaba_metadata_rejected() {
705 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
706 }
707
708 #[test]
709 fn validate_ipv6_fe80_link_local_rejected() {
710 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
711 }
712
713 #[test]
714 fn validate_file_protocol_rejected() {
715 assert!(validate_clone_url("file:///etc/passwd").is_err());
716 }
717
718 #[test]
719 fn validate_empty_string_rejected() {
720 assert!(validate_clone_url("").is_err());
721 }
722
723 #[test]
724 fn validate_rfc1918_10_allowed() {
725 assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
727 }
728
729 #[test]
730 fn validate_rfc1918_192_168_allowed() {
731 assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
732 }
733
734 #[test]
735 fn validate_rfc1918_172_16_allowed() {
736 assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
737 }
738
739 #[test]
740 fn validate_rfc1918_172_31_allowed() {
741 assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
742 }
743
744 #[test]
745 fn validate_ipv6_ula_fd_allowed() {
746 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
748 }
749
750 #[test]
752 fn port_https_default() {
753 assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
754 }
755
756 #[test]
757 fn port_explicit_overrides_default() {
758 assert_eq!(
759 port_of_git_url("https://gitlab.corp:8443/o/r.git"),
760 Some(8443)
761 );
762 }
763
764 #[test]
765 fn port_git_scheme_default() {
766 assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
767 }
768
769 #[test]
770 fn port_scp_like_is_ssh() {
771 assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
772 }
773
774 #[test]
775 fn port_ipv6_with_explicit_port() {
776 assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
777 }
778
779 #[test]
780 fn port_ipv6_default() {
781 assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
782 }
783
784 #[test]
785 fn validate_metadata_ip_literal_still_rejected() {
786 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
788 }
789
790 #[test]
791 fn validate_loopback_127_rejected() {
792 assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
793 }
794
795 #[test]
796 fn validate_localhost_rejected() {
797 assert!(validate_clone_url("https://localhost/repo.git").is_err());
798 }
799
800 #[test]
801 fn validate_unspecified_0_0_0_0_rejected() {
802 assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
803 }
804
805 #[test]
808 fn host_of_git_url_https_with_port_and_creds() {
809 assert_eq!(
810 host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
811 Some("gitlab.corp.com")
812 );
813 }
814
815 #[test]
816 fn host_of_git_url_scp_syntax() {
817 assert_eq!(
818 host_of_git_url("git@github.com:owner/repo.git").as_deref(),
819 Some("github.com")
820 );
821 }
822
823 #[test]
824 fn host_of_git_url_ipv6_literal() {
825 assert_eq!(
826 host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
827 Some("fe80::1")
828 );
829 }
830
831 #[test]
832 fn validate_clone_url_path_with_version_number_not_blocked() {
833 assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
835 assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
836 }
837
838 #[test]
841 fn bitbucket_server_uppercase_project_lowercased() {
842 let r = try_normalize_bitbucket_server(
843 "https",
844 "bb.corp.com",
845 "/projects/PROJ/repos/myrepo/browse",
846 );
847 assert_eq!(
848 r,
849 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
850 );
851 }
852
853 #[test]
854 fn bitbucket_server_without_projects_returns_none() {
855 assert!(
856 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
857 );
858 }
859
860 #[test]
861 fn bitbucket_server_missing_repos_segment_returns_none() {
862 assert!(
863 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
864 .is_none()
865 );
866 }
867
868 #[test]
871 fn gitlab_dash_tree_normalized() {
872 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
873 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
874 }
875
876 #[test]
877 fn gitlab_no_dash_returns_none() {
878 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
879 }
880
881 #[test]
882 fn gitlab_strips_existing_dot_git_before_readding() {
883 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
884 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
885 }
886
887 #[test]
890 fn github_tree_normalized() {
891 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
892 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
893 }
894
895 #[test]
896 fn github_non_github_host_returns_none() {
897 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
898 }
899
900 #[test]
901 fn github_plain_two_segment_path_returns_none() {
902 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
903 }
904
905 #[test]
906 fn github_unknown_third_segment_returns_none() {
907 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
908 }
909
910 #[test]
913 fn bitbucket_cloud_src_normalized() {
914 let r = try_normalize_bitbucket_cloud(
915 "https",
916 "bitbucket.org",
917 "/workspace/repo/src/main/README.md",
918 );
919 assert_eq!(
920 r,
921 Some("https://bitbucket.org/workspace/repo.git".to_owned())
922 );
923 }
924
925 #[test]
926 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
927 assert!(
928 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
929 );
930 }
931
932 #[test]
933 fn bitbucket_cloud_without_src_segment_returns_none() {
934 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
935 }
936
937 #[test]
940 fn parse_ref_line_all_fields() {
941 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
942 let r = parse_ref_line(line, GitRefKind::Branch);
943 assert_eq!(r.name, "main");
944 assert_eq!(r.sha, "abc1234");
945 assert!(r.date.is_some());
946 assert_eq!(r.message.as_deref(), Some("Initial commit"));
947 assert!(matches!(r.kind, GitRefKind::Branch));
948 }
949
950 #[test]
951 fn parse_ref_line_tag_kind() {
952 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
953 let r = parse_ref_line(line, GitRefKind::Tag);
954 assert_eq!(r.name, "v1.0.0");
955 assert!(matches!(r.kind, GitRefKind::Tag));
956 }
957
958 #[test]
959 fn parse_ref_line_name_only() {
960 let r = parse_ref_line("main", GitRefKind::Branch);
961 assert_eq!(r.name, "main");
962 assert_eq!(r.sha, "");
963 assert!(r.date.is_none());
964 assert!(r.message.is_none());
965 }
966
967 #[test]
968 fn parse_ref_line_invalid_date_gives_none() {
969 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
970 assert!(r.date.is_none());
971 assert_eq!(r.message.as_deref(), Some("msg"));
972 }
973
974 #[test]
975 fn parse_ref_line_empty_string() {
976 let r = parse_ref_line("", GitRefKind::Branch);
977 assert_eq!(r.name, "");
978 }
979
980 #[test]
983 fn parse_commit_line_all_fields() {
984 let line =
985 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
986 let c = parse_commit_line(line);
987 assert_eq!(c.sha, "abc1234567890abcdef");
988 assert_eq!(c.short_sha, "abc1234");
989 assert_eq!(c.author, "Alice Smith");
990 assert_eq!(c.subject, "Fix critical bug");
991 }
992
993 #[test]
994 fn parse_commit_line_empty() {
995 let c = parse_commit_line("");
996 assert_eq!(c.sha, "");
997 assert_eq!(c.short_sha, "");
998 assert_eq!(c.author, "");
999 assert_eq!(c.subject, "");
1000 }
1001
1002 #[test]
1003 fn parse_commit_line_partial_fields() {
1004 let c = parse_commit_line("sha1|sha_short");
1005 assert_eq!(c.sha, "sha1");
1006 assert_eq!(c.short_sha, "sha_short");
1007 assert_eq!(c.author, "");
1008 }
1009
1010 #[test]
1011 fn parse_commit_line_subject_with_pipe() {
1012 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
1014 let c = parse_commit_line(line);
1015 assert_eq!(c.subject, "subject with | pipe inside");
1016 }
1017
1018 #[test]
1021 fn parse_git_date_valid_rfc3339() {
1022 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
1023 assert!(dt.is_some());
1024 }
1025
1026 #[test]
1027 fn parse_git_date_invalid_returns_none() {
1028 assert!(parse_git_date("not-a-date").is_none());
1029 assert!(parse_git_date("").is_none());
1030 }
1031
1032 #[test]
1033 fn parse_git_date_with_offset_converts_to_utc() {
1034 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
1035 assert_eq!(dt.time().hour(), 7);
1037 }
1038}
1039
1040#[cfg(test)]
1047mod git_integration {
1048 use super::*;
1049 use std::path::Path;
1050 use tempfile::tempdir;
1051
1052 fn git(dir: &Path, args: &[&str]) {
1055 let status = std::process::Command::new("git")
1056 .args(args)
1057 .current_dir(dir)
1058 .env("GIT_AUTHOR_NAME", "Test")
1059 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1060 .env("GIT_COMMITTER_NAME", "Test")
1061 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1062 .status()
1063 .expect("git must be on PATH");
1064 assert!(status.success(), "git {args:?} failed");
1065 }
1066
1067 fn make_repo(dir: &Path) {
1069 git(dir, &["init", "-b", "main"]);
1070 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
1071 git(dir, &["add", "hello.txt"]);
1072 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
1073 }
1074
1075 #[test]
1078 fn run_git_success_returns_stdout() {
1079 let dir = tempdir().unwrap();
1080 make_repo(dir.path());
1081 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
1083 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
1084 }
1085
1086 #[test]
1087 fn run_git_failure_returns_error() {
1088 let dir = tempdir().unwrap();
1089 make_repo(dir.path());
1090 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
1091 assert!(result.is_err(), "nonexistent ref must return an error");
1092 }
1093
1094 #[test]
1097 fn clone_or_fetch_clones_local_repo() {
1098 let src = tempdir().unwrap();
1099 make_repo(src.path());
1100
1101 let dest_root = tempdir().unwrap();
1102 let dest = dest_root.path().join("clone");
1103
1104 std::fs::create_dir_all(&dest).unwrap();
1113 let src_str = src.path().to_str().unwrap();
1114 let dest_str = dest.to_str().unwrap();
1115 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1116 assert!(dest.join(".git").exists(), "clone must create .git dir");
1117
1118 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
1120 git(src.path(), &["add", "second.txt"]);
1121 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1122
1123 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
1128 }
1129
1130 #[test]
1131 fn clone_or_fetch_rejects_http_plain_url() {
1132 let dest = tempdir().unwrap();
1133 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
1134 assert!(
1135 result.is_err(),
1136 "http:// must be rejected by validate_clone_url"
1137 );
1138 }
1139
1140 #[test]
1141 fn clone_or_fetch_rejects_link_local_url() {
1142 let dest = tempdir().unwrap();
1143 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
1144 assert!(result.is_err());
1145 }
1146
1147 #[test]
1150 fn get_sha_returns_full_commit_hash() {
1151 let dir = tempdir().unwrap();
1152 make_repo(dir.path());
1153 let sha = get_sha(dir.path(), "HEAD").unwrap();
1154 assert_eq!(sha.len(), 40);
1155 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
1156 }
1157
1158 #[test]
1159 fn get_sha_nonexistent_ref_errors() {
1160 let dir = tempdir().unwrap();
1161 make_repo(dir.path());
1162 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
1163 }
1164
1165 #[test]
1168 fn list_commits_returns_at_least_one_commit() {
1169 let dir = tempdir().unwrap();
1170 make_repo(dir.path());
1171 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
1172 assert!(
1173 !commits.is_empty(),
1174 "must return at least the initial commit"
1175 );
1176 let c = &commits[0];
1177 assert_eq!(c.sha.len(), 40);
1178 assert!(!c.short_sha.is_empty());
1179 assert_eq!(c.author, "Test");
1180 assert_eq!(c.subject, "initial");
1181 }
1182
1183 #[test]
1184 fn list_commits_respects_limit() {
1185 let dir = tempdir().unwrap();
1186 make_repo(dir.path());
1187 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
1189 git(dir.path(), &["add", "b.txt"]);
1190 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1191
1192 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
1193 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
1194
1195 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
1196 assert_eq!(two.len(), 2, "limit=10 must return both commits");
1197 }
1198
1199 #[test]
1202 fn list_refs_returns_main_branch() {
1203 let src = tempdir().unwrap();
1204 make_repo(src.path());
1205
1206 let dest_root = tempdir().unwrap();
1208 let dest = dest_root.path().join("clone");
1209 let src_str = src.path().to_str().unwrap();
1210 let dest_str = dest.to_str().unwrap();
1211 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1212
1213 let refs = list_refs(&dest).unwrap();
1214 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
1215 assert!(
1216 branch_names.contains(&"main"),
1217 "branches must include 'main', got: {branch_names:?}"
1218 );
1219 }
1220
1221 #[test]
1222 fn list_refs_returns_tag() {
1223 let src = tempdir().unwrap();
1224 make_repo(src.path());
1225 git(src.path(), &["tag", "v1.0.0"]);
1226
1227 let dest_root = tempdir().unwrap();
1228 let dest = dest_root.path().join("clone");
1229 let src_str = src.path().to_str().unwrap();
1230 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
1231 run_git(&dest, &["fetch", "--tags"]).unwrap();
1233
1234 let refs = list_refs(&dest).unwrap();
1235 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
1236 assert!(
1237 tag_names.contains(&"v1.0.0"),
1238 "tags must include 'v1.0.0', got: {tag_names:?}"
1239 );
1240 }
1241
1242 #[test]
1245 fn create_and_destroy_worktree() {
1246 let repo = tempdir().unwrap();
1247 make_repo(repo.path());
1248
1249 let sha = get_sha(repo.path(), "HEAD").unwrap();
1250
1251 let wt_root = tempdir().unwrap();
1252 let wt_path = wt_root.path().join("worktree");
1253
1254 create_worktree(repo.path(), &sha, &wt_path).unwrap();
1255 assert!(
1256 wt_path.exists(),
1257 "worktree directory must exist after creation"
1258 );
1259 assert!(
1260 wt_path.join("hello.txt").exists(),
1261 "worktree must contain committed files"
1262 );
1263
1264 destroy_worktree(repo.path(), &wt_path).unwrap();
1265 assert!(
1266 !wt_path.exists(),
1267 "worktree directory must be removed after destroy"
1268 );
1269 }
1270
1271 #[test]
1272 fn destroy_worktree_on_nonexistent_path_succeeds() {
1273 let repo = tempdir().unwrap();
1275 make_repo(repo.path());
1276 let nonexistent = repo.path().join("does_not_exist");
1277 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
1278 }
1279}