1use std::io::Read as _;
5use std::path::Path;
6use std::process::Stdio;
7use std::sync::OnceLock;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
13
14fn git_host_allowlist() -> &'static [String] {
18 static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
19 ALLOW.get_or_init(|| {
20 std::env::var("SLOC_GIT_HOST_ALLOWLIST")
21 .unwrap_or_default()
22 .split(',')
23 .map(|s| s.trim().to_lowercase())
24 .filter(|s| !s.is_empty())
25 .collect()
26 })
27}
28
29fn require_host_allowlist() -> bool {
36 static REQ: OnceLock<bool> = OnceLock::new();
37 *REQ.get_or_init(|| {
38 std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
39 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
40 })
41}
42
43fn ssl_no_verify() -> bool {
49 static NO_VERIFY: OnceLock<bool> = OnceLock::new();
50 *NO_VERIFY.get_or_init(|| std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some())
51}
52
53fn git_timeout() -> Duration {
57 static TIMEOUT: OnceLock<Duration> = OnceLock::new();
58 *TIMEOUT.get_or_init(|| {
59 let secs = std::env::var("SLOC_GIT_TIMEOUT")
60 .ok()
61 .and_then(|v| v.parse::<u64>().ok())
62 .filter(|&s| s > 0)
63 .unwrap_or(300);
64 Duration::from_secs(secs)
65 })
66}
67
68fn network_git_config() -> Vec<String> {
83 let mut cfg = vec![
84 "http.followRedirects=false".to_owned(),
85 "http.lowSpeedLimit=1000".to_owned(),
86 "http.lowSpeedTime=30".to_owned(),
87 ];
88 if cfg!(windows) {
89 cfg.push("http.sslBackend=schannel".to_owned());
90 }
91 if ssl_no_verify() {
92 cfg.push("http.sslVerify=false".to_owned());
93 }
94 cfg
95}
96
97fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
99 let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
100 for c in cfg {
101 v.push("-c");
102 v.push(c.as_str());
103 }
104 v.extend_from_slice(tail);
105 v
106}
107
108fn persist_repo_config(dest: &Path, cfg: &[String]) {
115 for kv in cfg {
116 if let Some((key, value)) = kv.split_once('=') {
117 let _ = run_git(dest, &["config", key, value]);
118 }
119 }
120}
121
122fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
125 let mut cmd = std::process::Command::new("git");
126 cmd.env("GIT_TERMINAL_PROMPT", "0")
135 .env("GCM_INTERACTIVE", "never")
136 .env("GIT_ASKPASS", "")
137 .env("SSH_ASKPASS", "")
138 .args(args)
139 .current_dir(repo)
140 .stdin(Stdio::null())
141 .stdout(Stdio::piped())
142 .stderr(Stdio::piped());
143 let mut child = cmd.spawn().context("failed to spawn git process")?;
144
145 let mut out_pipe = child.stdout.take();
149 let mut err_pipe = child.stderr.take();
150 let out_handle = std::thread::spawn(move || {
151 let mut buf = Vec::new();
152 if let Some(p) = out_pipe.as_mut() {
153 let _ = p.read_to_end(&mut buf);
154 }
155 buf
156 });
157 let err_handle = std::thread::spawn(move || {
158 let mut buf = Vec::new();
159 if let Some(p) = err_pipe.as_mut() {
160 let _ = p.read_to_end(&mut buf);
161 }
162 buf
163 });
164
165 let timeout = git_timeout();
167 let start = Instant::now();
168 let status = loop {
169 if let Some(status) = child.try_wait().context("failed to poll git process")? {
170 break status;
171 }
172 if start.elapsed() >= timeout {
173 let _ = child.kill();
174 let _ = child.wait();
175 bail!(
176 "git {} timed out after {}s — the remote did not respond in time. \
177 On a corporate network this usually means a proxy or VPN is slow or \
178 blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
179 or check your proxy/VPN configuration.",
180 args.first().copied().unwrap_or(""),
181 timeout.as_secs()
182 );
183 }
184 std::thread::sleep(Duration::from_millis(100));
185 };
186
187 let stdout = out_handle.join().unwrap_or_default();
188 let stderr = err_handle.join().unwrap_or_default();
189 if !status.success() {
190 let stderr = String::from_utf8_lossy(&stderr);
191 bail!(
192 "git {}: {}",
193 args.first().copied().unwrap_or(""),
194 stderr.trim()
195 );
196 }
197 Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
198}
199
200#[must_use]
209pub fn normalize_git_url(raw: &str) -> String {
210 let url = raw.trim();
211 if url.starts_with("git@") || url.starts_with("ssh://") {
212 return url.to_owned();
213 }
214 let scheme = if url.starts_with("https://") {
215 "https"
216 } else if url.starts_with("http://") {
217 "http"
218 } else {
219 return url.to_owned();
220 };
221 let authority_and_path = &url[scheme.len() + 3..];
222 let (host, path) = authority_and_path
223 .find('/')
224 .map_or((authority_and_path, "/"), |i| {
225 (&authority_and_path[..i], &authority_and_path[i..])
226 });
227 let path = path.trim_end_matches('/');
228
229 try_normalize_bitbucket_server(scheme, host, path)
230 .or_else(|| try_normalize_gitlab(scheme, host, path))
231 .or_else(|| try_normalize_github(scheme, host, path))
232 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
233 .unwrap_or_else(|| url.to_owned())
234}
235
236fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
240 let path_lower = path.to_lowercase();
241 let proj_pos = path_lower.find("/projects/")?;
242 let after = &path[proj_pos + "/projects/".len()..];
243 let parts: Vec<&str> = after.splitn(4, '/').collect();
244 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
245 return None;
246 }
247 let context = &path[..proj_pos];
248 let project = parts[0].to_lowercase();
249 let repo = parts[2].trim_end_matches(".git");
250 Some(format!(
251 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
252 ))
253}
254
255fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
258 let idx = path.find("/-/")?;
259 let repo_path = path[..idx].trim_end_matches(".git");
260 Some(format!("{scheme}://{host}{repo_path}.git"))
261}
262
263fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
266 if host != "github.com" && !host.ends_with(".github.com") {
267 return None;
268 }
269 let p = path.trim_start_matches('/');
270 let parts: Vec<&str> = p.splitn(4, '/').collect();
271 if parts.len() < 3
272 || !matches!(
273 parts[2],
274 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
275 )
276 {
277 return None;
278 }
279 let owner = parts[0];
280 let repo = parts[1].trim_end_matches(".git");
281 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
282}
283
284fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
287 if host != "bitbucket.org" {
288 return None;
289 }
290 let p = path.trim_start_matches('/');
291 let parts: Vec<&str> = p.splitn(4, '/').collect();
292 if parts.len() < 3 || parts[2] != "src" {
293 return None;
294 }
295 let ws = parts[0];
296 let repo = parts[1].trim_end_matches(".git");
297 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
298}
299
300fn validate_clone_url(url: &str) -> Result<()> {
303 let lower = url.to_lowercase();
304 let allowed = ["https://", "git://", "ssh://", "git@"];
307 if !allowed.iter().any(|p| lower.starts_with(p)) {
308 bail!(
309 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
310 permitted (got {url:?})"
311 );
312 }
313 let Some(host) = host_of_git_url(url) else {
320 return Ok(());
321 };
322 check_host_allowed(&host)?;
323 check_resolved_ips(&host, url)?;
324 Ok(())
325}
326
327fn check_host_allowed(host: &str) -> Result<()> {
331 let allow = git_host_allowlist();
337 if allow.is_empty() {
338 if require_host_allowlist() {
339 bail!(
340 "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
341 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
342 );
343 }
344 } else if !allow.iter().any(|h| h == host) {
345 bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
346 }
347 if is_ssrf_blocked_host(host) {
348 bail!(
349 "git URL rejected: loopback, link-local, and cloud-metadata \
350 addresses are not permitted (host {host:?})"
351 );
352 }
353 Ok(())
354}
355
356fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
362 let Some(port) = port_of_git_url(url) else {
363 return Ok(());
364 };
365 let Ok(addrs) = resolve_host_port(host, port) else {
366 return Ok(());
367 };
368 for addr in addrs {
369 if is_ssrf_blocked_ip(addr.ip()) {
370 bail!(
371 "git URL rejected: host {host:?} resolves to a blocked \
372 address {} (loopback/link-local/cloud-metadata)",
373 addr.ip()
374 );
375 }
376 }
377 Ok(())
378}
379
380#[cfg(not(test))]
387fn resolve_host_port(
388 host: &str,
389 port: u16,
390) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
391 use std::net::ToSocketAddrs as _;
392 (host, port).to_socket_addrs()
393}
394
395#[cfg(test)]
396fn resolve_host_port(
397 host: &str,
398 port: u16,
399) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
400 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
401 let ip = host
405 .parse::<IpAddr>()
406 .unwrap_or(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)));
407 Ok(vec![SocketAddr::new(ip, port)].into_iter())
408}
409
410fn host_of_git_url(url: &str) -> Option<String> {
413 let u = url.trim();
414 if let Some(rest) = u.strip_prefix("git@") {
416 let host = rest.split(':').next().unwrap_or(rest);
417 return Some(host.to_lowercase());
418 }
419 let after_scheme = u.split("://").nth(1)?;
421 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
422 let authority = authority.rsplit('@').next().unwrap_or(authority);
424 let host = authority.strip_prefix('[').map_or_else(
426 || authority.split(':').next().unwrap_or(authority).to_string(),
427 |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
428 );
429 Some(host.to_lowercase())
430}
431
432fn port_of_git_url(url: &str) -> Option<u16> {
436 let u = url.trim();
437 if u.starts_with("git@") {
439 return Some(22);
440 }
441 let (scheme, after_scheme) = u.split_once("://")?;
442 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
443 let authority = authority.rsplit('@').next().unwrap_or(authority);
444 let explicit = authority.strip_prefix('[').map_or_else(
446 || {
448 authority
449 .rsplit_once(':')
450 .and_then(|(_, p)| p.parse::<u16>().ok())
451 },
452 |stripped| {
454 stripped
455 .split_once("]:")
456 .and_then(|(_, p)| p.parse::<u16>().ok())
457 },
458 );
459 explicit.or_else(|| match scheme.to_lowercase().as_str() {
460 "https" => Some(443),
461 "git" => Some(9418),
462 "ssh" => Some(22),
463 _ => None,
464 })
465}
466
467const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
469 "metadata.google.internal",
470 "metadata.internal",
471 "instance-data",
472];
473
474fn is_ssrf_blocked_host(host: &str) -> bool {
478 let h = host
479 .trim()
480 .trim_start_matches('[')
481 .trim_end_matches(']')
482 .to_lowercase();
483 if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
484 return true;
485 }
486 h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
487}
488
489fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
492 match ip {
493 std::net::IpAddr::V4(v4) => {
494 v4.is_loopback()
495 || v4.is_link_local()
496 || v4.is_unspecified()
497 || v4.is_broadcast()
498 || v4.is_multicast()
499 || v4.octets() == [100, 100, 100, 200] }
501 std::net::IpAddr::V6(v6) => {
502 v6.is_loopback()
503 || v6.is_unspecified()
504 || v6.is_multicast()
505 || (v6.segments()[0] & 0xffc0) == 0xfe80 }
507 }
508}
509
510pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
519 let normalized = normalize_git_url(url);
520 let url = normalized.as_str();
521 validate_clone_url(url)?;
522 let cfg = network_git_config();
526 if dest.join(".git").exists() {
527 let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
528 run_git(dest, &args)?;
529 return Ok(());
530 }
531
532 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
533 let dest_str = dest.to_str().unwrap_or(".");
534 let parent = dest.parent().unwrap_or(dest);
535
536 let fast = with_config(
544 &cfg,
545 &[
546 "clone",
547 "--filter=blob:none",
548 "--no-checkout",
549 "--no-single-branch",
550 url,
551 dest_str,
552 ],
553 );
554 if let Err(e) = run_git(parent, &fast) {
555 let msg = e.to_string().to_lowercase();
561 if !(msg.contains("filter") || msg.contains("partial")) {
562 return Err(e);
563 }
564 let _ = std::fs::remove_dir_all(dest);
565 std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
566 let full = with_config(
567 &cfg,
568 &[
569 "clone",
570 "--no-checkout",
571 "--no-single-branch",
572 url,
573 dest_str,
574 ],
575 );
576 run_git(parent, &full)?;
577 }
578 persist_repo_config(dest, &cfg);
579 Ok(())
580}
581
582pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
587 run_git(repo, &["rev-parse", ref_name])
588}
589
590pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
603 let candidates = [
604 ref_name.to_owned(),
605 format!("origin/{ref_name}"),
606 format!("refs/remotes/origin/{ref_name}"),
607 ];
608 for cand in &candidates {
609 let spec = format!("{cand}^{{commit}}");
610 if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
611 && !sha.is_empty()
612 {
613 return Ok(sha);
614 }
615 }
616 bail!(
617 "ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
618 and as refs/remotes/origin/{ref_name})"
619 );
620}
621
622pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
631 let wt = worktree_path.to_str().unwrap_or(".");
632 let committish = resolve_committish(repo, ref_name)?;
633 run_git(repo, &["worktree", "add", "--detach", wt, &committish])?;
634 Ok(())
635}
636
637pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
642 let wt = worktree_path.to_str().unwrap_or(".");
643 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
644 Ok(())
645}
646
647pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
654 Ok(RepoRefs {
655 branches: list_branches(repo)?,
656 tags: list_tags(repo)?,
657 recent_commits: list_commits(repo, "HEAD", 40)?,
658 })
659}
660
661fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
662 let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
668 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
672 let refs = out
673 .lines()
674 .filter(|l| !l.trim().is_empty())
675 .filter_map(|l| {
677 let (symref, rest) = l.split_once('|')?;
678 if symref.trim().is_empty() {
679 Some(rest)
680 } else {
681 None
682 }
683 })
684 .map(|l| parse_ref_line(l, GitRefKind::Branch))
685 .map(|mut r| {
686 if let Some(slash) = r.name.find('/') {
688 r.name = r.name[slash + 1..].to_owned();
689 }
690 r
691 })
692 .collect::<Vec<_>>();
693 Ok(refs)
694}
695
696fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
697 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
698 let out = run_git(
699 repo,
700 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
701 )?;
702 Ok(out
703 .lines()
704 .filter(|l| !l.trim().is_empty())
705 .map(|l| parse_ref_line(l, GitRefKind::Tag))
706 .collect())
707}
708
709fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
710 let parts: Vec<&str> = line.splitn(4, '|').collect();
711 let name = parts.first().copied().unwrap_or("").to_owned();
712 let sha = parts.get(1).copied().unwrap_or("").to_owned();
713 let date = parts.get(2).copied().and_then(parse_git_date);
714 let message = parts.get(3).map(|s| (*s).to_owned());
715 GitRef {
716 kind,
717 name,
718 sha,
719 date,
720 message,
721 }
722}
723
724pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
731 let fmt = "%H|%h|%an|%aI|%s";
732 let n = format!("-{limit}");
733 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
734 Ok(out
735 .lines()
736 .filter(|l| !l.trim().is_empty())
737 .map(parse_commit_line)
738 .collect())
739}
740
741fn parse_commit_line(line: &str) -> GitCommit {
742 let p: Vec<&str> = line.splitn(5, '|').collect();
743 let sha = p.first().copied().unwrap_or("").to_owned();
744 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
745 let author = p.get(2).copied().unwrap_or("").to_owned();
746 let date = p
747 .get(3)
748 .copied()
749 .and_then(parse_git_date)
750 .unwrap_or_default();
751 let subject = p.get(4).copied().unwrap_or("").to_owned();
752 GitCommit {
753 sha,
754 short_sha,
755 author,
756 date,
757 subject,
758 }
759}
760
761fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
762 chrono::DateTime::parse_from_rfc3339(s)
763 .ok()
764 .map(|d| d.with_timezone(&chrono::Utc))
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::GitRefKind;
771 use chrono::Timelike as _;
772
773 #[test]
776 fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
777 assert!(is_ssrf_blocked_host("localhost"));
778 assert!(is_ssrf_blocked_host("metadata.google.internal"));
779 assert!(is_ssrf_blocked_host("metadata.internal"));
780 assert!(is_ssrf_blocked_host("instance-data"));
781 assert!(is_ssrf_blocked_host(" LOCALHOST "));
783 assert!(is_ssrf_blocked_host("127.0.0.1"));
785 assert!(is_ssrf_blocked_host("[::1]"));
786 assert!(is_ssrf_blocked_host("169.254.169.254"));
787 }
788
789 #[test]
790 fn require_host_allowlist_defaults_false() {
791 assert!(!require_host_allowlist());
793 }
794
795 #[test]
796 fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
797 assert!(check_host_allowed("github.com").is_ok());
799 assert!(check_host_allowed("localhost").is_err());
800 }
801
802 #[test]
803 fn is_ssrf_blocked_host_allows_public_hosts() {
804 assert!(!is_ssrf_blocked_host("github.com"));
805 assert!(!is_ssrf_blocked_host("example.com"));
806 assert!(!is_ssrf_blocked_host("192.168.1.10"));
808 assert!(!is_ssrf_blocked_host("10.0.0.1"));
809 }
810
811 #[test]
814 fn network_git_config_always_hardens_redirects_and_lowspeed() {
815 let cfg = network_git_config();
816 assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
817 assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
818 assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
819 }
820
821 #[cfg(windows)]
822 #[test]
823 fn network_git_config_uses_schannel_on_windows() {
824 let cfg = network_git_config();
827 assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
828 }
829
830 #[test]
831 fn with_config_interleaves_dash_c_pairs_before_tail() {
832 let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
833 let args = with_config(&cfg, &["clone", "url", "dest"]);
834 assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
835 }
836
837 #[test]
838 fn with_config_empty_cfg_is_just_the_tail() {
839 let cfg: Vec<String> = Vec::new();
840 assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
841 }
842
843 #[test]
844 fn git_timeout_is_positive() {
845 assert!(git_timeout().as_secs() > 0);
847 }
848
849 #[test]
852 fn normalize_github_tree_url() {
853 assert_eq!(
854 normalize_git_url("https://github.com/owner/repo/tree/main"),
855 "https://github.com/owner/repo.git"
856 );
857 }
858
859 #[test]
860 fn normalize_github_blob_url() {
861 assert_eq!(
862 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
863 "https://github.com/owner/repo.git"
864 );
865 }
866
867 #[test]
868 fn normalize_github_commits_url() {
869 assert_eq!(
870 normalize_git_url("https://github.com/owner/repo/commits/main"),
871 "https://github.com/owner/repo.git"
872 );
873 }
874
875 #[test]
876 fn normalize_github_releases_url() {
877 assert_eq!(
878 normalize_git_url("https://github.com/owner/repo/releases"),
879 "https://github.com/owner/repo.git"
880 );
881 }
882
883 #[test]
884 fn normalize_github_tags_url() {
885 assert_eq!(
886 normalize_git_url("https://github.com/owner/repo/tags"),
887 "https://github.com/owner/repo.git"
888 );
889 }
890
891 #[test]
892 fn normalize_github_branches_url() {
893 assert_eq!(
894 normalize_git_url("https://github.com/owner/repo/branches"),
895 "https://github.com/owner/repo.git"
896 );
897 }
898
899 #[test]
900 fn normalize_github_plain_clone_url_unchanged() {
901 let url = "https://github.com/owner/repo.git";
902 assert_eq!(normalize_git_url(url), url);
903 }
904
905 #[test]
906 fn normalize_gitlab_tree_url() {
907 assert_eq!(
908 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
909 "https://gitlab.com/group/subgroup/repo.git"
910 );
911 }
912
913 #[test]
914 fn normalize_gitlab_blob_url() {
915 assert_eq!(
916 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
917 "https://gitlab.com/org/repo.git"
918 );
919 }
920
921 #[test]
922 fn normalize_gitlab_self_hosted() {
923 assert_eq!(
924 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
925 "https://gitlab.corp.com/team/project.git"
926 );
927 }
928
929 #[test]
930 fn normalize_bitbucket_server_browse_url() {
931 assert_eq!(
932 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
933 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
934 );
935 }
936
937 #[test]
938 fn normalize_bitbucket_server_with_context() {
939 assert_eq!(
940 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
941 "https://host.com/ctx/scm/proj/repo.git"
942 );
943 }
944
945 #[test]
946 fn normalize_bitbucket_cloud_src_url() {
947 assert_eq!(
948 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
949 "https://bitbucket.org/workspace/repo.git"
950 );
951 }
952
953 #[test]
954 fn normalize_ssh_url_unchanged() {
955 let url = "git@github.com:owner/repo.git";
956 assert_eq!(normalize_git_url(url), url);
957 }
958
959 #[test]
960 fn normalize_ssh_protocol_url_unchanged() {
961 let url = "ssh://git@github.com/owner/repo.git";
962 assert_eq!(normalize_git_url(url), url);
963 }
964
965 #[test]
966 fn normalize_trims_leading_trailing_whitespace() {
967 assert_eq!(
968 normalize_git_url(" https://github.com/owner/repo/tree/main "),
969 "https://github.com/owner/repo.git"
970 );
971 }
972
973 #[test]
974 fn normalize_http_url_without_match_returned_unchanged() {
975 let url = "http://internal.corp.com/repo.git";
976 assert_eq!(normalize_git_url(url), url);
977 }
978
979 #[test]
982 fn validate_https_url_ok() {
983 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
984 }
985
986 #[test]
987 fn validate_git_protocol_url_ok() {
988 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
989 }
990
991 #[test]
992 fn validate_ssh_protocol_url_ok() {
993 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
994 }
995
996 #[test]
997 fn validate_git_at_url_ok() {
998 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
999 }
1000
1001 #[test]
1002 fn validate_http_plain_rejected() {
1003 assert!(
1004 validate_clone_url("http://github.com/owner/repo.git").is_err(),
1005 "plain http:// must be rejected"
1006 );
1007 }
1008
1009 #[test]
1010 fn validate_link_local_169_254_rejected() {
1011 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1012 }
1013
1014 #[test]
1015 fn validate_google_metadata_endpoint_rejected() {
1016 assert!(
1017 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
1018 );
1019 }
1020
1021 #[test]
1022 fn validate_alibaba_metadata_rejected() {
1023 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
1024 }
1025
1026 #[test]
1027 fn validate_ipv6_fe80_link_local_rejected() {
1028 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
1029 }
1030
1031 #[test]
1032 fn validate_file_protocol_rejected() {
1033 assert!(validate_clone_url("file:///etc/passwd").is_err());
1034 }
1035
1036 #[test]
1037 fn validate_empty_string_rejected() {
1038 assert!(validate_clone_url("").is_err());
1039 }
1040
1041 #[test]
1042 fn validate_rfc1918_10_allowed() {
1043 assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
1045 }
1046
1047 #[test]
1048 fn validate_rfc1918_192_168_allowed() {
1049 assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
1050 }
1051
1052 #[test]
1053 fn validate_rfc1918_172_16_allowed() {
1054 assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
1055 }
1056
1057 #[test]
1058 fn validate_rfc1918_172_31_allowed() {
1059 assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
1060 }
1061
1062 #[test]
1063 fn validate_ipv6_ula_fd_allowed() {
1064 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
1066 }
1067
1068 #[test]
1070 fn port_https_default() {
1071 assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
1072 }
1073
1074 #[test]
1075 fn port_explicit_overrides_default() {
1076 assert_eq!(
1077 port_of_git_url("https://gitlab.corp:8443/o/r.git"),
1078 Some(8443)
1079 );
1080 }
1081
1082 #[test]
1083 fn port_git_scheme_default() {
1084 assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
1085 }
1086
1087 #[test]
1088 fn port_scp_like_is_ssh() {
1089 assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
1090 }
1091
1092 #[test]
1093 fn port_ipv6_with_explicit_port() {
1094 assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
1095 }
1096
1097 #[test]
1098 fn port_ipv6_default() {
1099 assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
1100 }
1101
1102 #[test]
1103 fn validate_metadata_ip_literal_still_rejected() {
1104 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1106 }
1107
1108 #[test]
1109 fn validate_loopback_127_rejected() {
1110 assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
1111 }
1112
1113 #[test]
1114 fn validate_localhost_rejected() {
1115 assert!(validate_clone_url("https://localhost/repo.git").is_err());
1116 }
1117
1118 #[test]
1119 fn validate_unspecified_0_0_0_0_rejected() {
1120 assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
1121 }
1122
1123 #[test]
1128 fn host_of_git_url_https_with_port_and_creds() {
1129 assert_eq!(
1130 host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
1131 Some("gitlab.corp.com")
1132 );
1133 }
1134
1135 #[test]
1136 fn host_of_git_url_scp_syntax() {
1137 assert_eq!(
1138 host_of_git_url("git@github.com:owner/repo.git").as_deref(),
1139 Some("github.com")
1140 );
1141 }
1142
1143 #[test]
1144 fn host_of_git_url_ipv6_literal() {
1145 assert_eq!(
1146 host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
1147 Some("fe80::1")
1148 );
1149 }
1150
1151 #[test]
1152 fn validate_clone_url_path_with_version_number_not_blocked() {
1153 assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
1155 assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
1156 }
1157
1158 #[test]
1161 fn bitbucket_server_uppercase_project_lowercased() {
1162 let r = try_normalize_bitbucket_server(
1163 "https",
1164 "bb.corp.com",
1165 "/projects/PROJ/repos/myrepo/browse",
1166 );
1167 assert_eq!(
1168 r,
1169 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
1170 );
1171 }
1172
1173 #[test]
1174 fn bitbucket_server_without_projects_returns_none() {
1175 assert!(
1176 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
1177 );
1178 }
1179
1180 #[test]
1181 fn bitbucket_server_missing_repos_segment_returns_none() {
1182 assert!(
1183 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
1184 .is_none()
1185 );
1186 }
1187
1188 #[test]
1191 fn gitlab_dash_tree_normalized() {
1192 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
1193 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1194 }
1195
1196 #[test]
1197 fn gitlab_no_dash_returns_none() {
1198 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
1199 }
1200
1201 #[test]
1202 fn gitlab_strips_existing_dot_git_before_readding() {
1203 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
1204 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1205 }
1206
1207 #[test]
1210 fn github_tree_normalized() {
1211 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
1212 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
1213 }
1214
1215 #[test]
1216 fn github_non_github_host_returns_none() {
1217 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
1218 }
1219
1220 #[test]
1221 fn github_plain_two_segment_path_returns_none() {
1222 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
1223 }
1224
1225 #[test]
1226 fn github_unknown_third_segment_returns_none() {
1227 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
1228 }
1229
1230 #[test]
1233 fn bitbucket_cloud_src_normalized() {
1234 let r = try_normalize_bitbucket_cloud(
1235 "https",
1236 "bitbucket.org",
1237 "/workspace/repo/src/main/README.md",
1238 );
1239 assert_eq!(
1240 r,
1241 Some("https://bitbucket.org/workspace/repo.git".to_owned())
1242 );
1243 }
1244
1245 #[test]
1246 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
1247 assert!(
1248 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
1249 );
1250 }
1251
1252 #[test]
1253 fn bitbucket_cloud_without_src_segment_returns_none() {
1254 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
1255 }
1256
1257 #[test]
1260 fn parse_ref_line_all_fields() {
1261 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
1262 let r = parse_ref_line(line, GitRefKind::Branch);
1263 assert_eq!(r.name, "main");
1264 assert_eq!(r.sha, "abc1234");
1265 assert!(r.date.is_some());
1266 assert_eq!(r.message.as_deref(), Some("Initial commit"));
1267 assert!(matches!(r.kind, GitRefKind::Branch));
1268 }
1269
1270 #[test]
1271 fn parse_ref_line_tag_kind() {
1272 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
1273 let r = parse_ref_line(line, GitRefKind::Tag);
1274 assert_eq!(r.name, "v1.0.0");
1275 assert!(matches!(r.kind, GitRefKind::Tag));
1276 }
1277
1278 #[test]
1279 fn parse_ref_line_name_only() {
1280 let r = parse_ref_line("main", GitRefKind::Branch);
1281 assert_eq!(r.name, "main");
1282 assert_eq!(r.sha, "");
1283 assert!(r.date.is_none());
1284 assert!(r.message.is_none());
1285 }
1286
1287 #[test]
1288 fn parse_ref_line_invalid_date_gives_none() {
1289 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
1290 assert!(r.date.is_none());
1291 assert_eq!(r.message.as_deref(), Some("msg"));
1292 }
1293
1294 #[test]
1295 fn parse_ref_line_empty_string() {
1296 let r = parse_ref_line("", GitRefKind::Branch);
1297 assert_eq!(r.name, "");
1298 }
1299
1300 #[test]
1303 fn parse_commit_line_all_fields() {
1304 let line =
1305 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
1306 let c = parse_commit_line(line);
1307 assert_eq!(c.sha, "abc1234567890abcdef");
1308 assert_eq!(c.short_sha, "abc1234");
1309 assert_eq!(c.author, "Alice Smith");
1310 assert_eq!(c.subject, "Fix critical bug");
1311 }
1312
1313 #[test]
1314 fn parse_commit_line_empty() {
1315 let c = parse_commit_line("");
1316 assert_eq!(c.sha, "");
1317 assert_eq!(c.short_sha, "");
1318 assert_eq!(c.author, "");
1319 assert_eq!(c.subject, "");
1320 }
1321
1322 #[test]
1323 fn parse_commit_line_partial_fields() {
1324 let c = parse_commit_line("sha1|sha_short");
1325 assert_eq!(c.sha, "sha1");
1326 assert_eq!(c.short_sha, "sha_short");
1327 assert_eq!(c.author, "");
1328 }
1329
1330 #[test]
1331 fn parse_commit_line_subject_with_pipe() {
1332 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
1334 let c = parse_commit_line(line);
1335 assert_eq!(c.subject, "subject with | pipe inside");
1336 }
1337
1338 #[test]
1341 fn parse_git_date_valid_rfc3339() {
1342 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
1343 assert!(dt.is_some());
1344 }
1345
1346 #[test]
1347 fn parse_git_date_invalid_returns_none() {
1348 assert!(parse_git_date("not-a-date").is_none());
1349 assert!(parse_git_date("").is_none());
1350 }
1351
1352 #[test]
1353 fn parse_git_date_with_offset_converts_to_utc() {
1354 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
1355 assert_eq!(dt.time().hour(), 7);
1357 }
1358
1359 #[test]
1360 fn port_of_git_url_unknown_scheme_returns_none() {
1361 assert_eq!(port_of_git_url("https://host/repo"), Some(443));
1363 assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
1364 assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
1365 assert_eq!(port_of_git_url("file://host/repo"), None);
1367 assert_eq!(port_of_git_url("ftp://host/repo"), None);
1368 }
1369}
1370
1371#[cfg(test)]
1378mod git_integration {
1379 use super::*;
1380 use std::path::Path;
1381 use tempfile::tempdir;
1382
1383 fn git(dir: &Path, args: &[&str]) {
1386 let status = std::process::Command::new("git")
1387 .args(args)
1388 .current_dir(dir)
1389 .env("GIT_AUTHOR_NAME", "Test")
1390 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1391 .env("GIT_COMMITTER_NAME", "Test")
1392 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1393 .status()
1394 .expect("git must be on PATH");
1395 assert!(status.success(), "git {args:?} failed");
1396 }
1397
1398 fn make_repo(dir: &Path) {
1400 git(dir, &["init", "-b", "main"]);
1401 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
1402 git(dir, &["add", "hello.txt"]);
1403 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
1404 }
1405
1406 #[test]
1409 fn run_git_success_returns_stdout() {
1410 let dir = tempdir().unwrap();
1411 make_repo(dir.path());
1412 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
1414 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
1415 }
1416
1417 #[test]
1418 fn run_git_failure_returns_error() {
1419 let dir = tempdir().unwrap();
1420 make_repo(dir.path());
1421 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
1422 assert!(result.is_err(), "nonexistent ref must return an error");
1423 }
1424
1425 #[test]
1428 fn clone_or_fetch_clones_local_repo() {
1429 let src = tempdir().unwrap();
1430 make_repo(src.path());
1431
1432 let dest_root = tempdir().unwrap();
1433 let dest = dest_root.path().join("clone");
1434
1435 std::fs::create_dir_all(&dest).unwrap();
1444 let src_str = src.path().to_str().unwrap();
1445 let dest_str = dest.to_str().unwrap();
1446 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1447 assert!(dest.join(".git").exists(), "clone must create .git dir");
1448
1449 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
1451 git(src.path(), &["add", "second.txt"]);
1452 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1453
1454 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
1459 }
1460
1461 #[test]
1462 fn list_branches_excludes_origin_head_symref() {
1463 let src = tempdir().unwrap();
1467 let inner = src.path().join("inner");
1468 std::fs::create_dir_all(&inner).unwrap();
1469 make_repo(&inner);
1470 git(&inner, &["branch", "feature-x"]);
1471
1472 let dest_root = tempdir().unwrap();
1473 let dest = dest_root.path().join("clone");
1474 let src_str = inner.to_str().unwrap();
1475 let dest_str = dest.to_str().unwrap();
1476 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1477 let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
1479
1480 let branches = list_branches(&dest).unwrap();
1481 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
1482 assert!(
1483 !names.contains(&"origin"),
1484 "origin/HEAD symref must not appear as a branch: {names:?}"
1485 );
1486 assert!(
1487 names.contains(&"main"),
1488 "main branch must be listed: {names:?}"
1489 );
1490 assert!(
1491 names.contains(&"feature-x"),
1492 "real branches must still be listed: {names:?}"
1493 );
1494 }
1495
1496 #[test]
1497 fn clone_or_fetch_rejects_http_plain_url() {
1498 let dest = tempdir().unwrap();
1499 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
1500 assert!(
1501 result.is_err(),
1502 "http:// must be rejected by validate_clone_url"
1503 );
1504 }
1505
1506 #[test]
1507 fn clone_or_fetch_rejects_link_local_url() {
1508 let dest = tempdir().unwrap();
1509 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
1510 assert!(result.is_err());
1511 }
1512
1513 #[test]
1516 fn get_sha_returns_full_commit_hash() {
1517 let dir = tempdir().unwrap();
1518 make_repo(dir.path());
1519 let sha = get_sha(dir.path(), "HEAD").unwrap();
1520 assert_eq!(sha.len(), 40);
1521 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
1522 }
1523
1524 #[test]
1525 fn get_sha_nonexistent_ref_errors() {
1526 let dir = tempdir().unwrap();
1527 make_repo(dir.path());
1528 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
1529 }
1530
1531 #[test]
1534 fn list_commits_returns_at_least_one_commit() {
1535 let dir = tempdir().unwrap();
1536 make_repo(dir.path());
1537 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
1538 assert!(
1539 !commits.is_empty(),
1540 "must return at least the initial commit"
1541 );
1542 let c = &commits[0];
1543 assert_eq!(c.sha.len(), 40);
1544 assert!(!c.short_sha.is_empty());
1545 assert_eq!(c.author, "Test");
1546 assert_eq!(c.subject, "initial");
1547 }
1548
1549 #[test]
1550 fn list_commits_respects_limit() {
1551 let dir = tempdir().unwrap();
1552 make_repo(dir.path());
1553 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
1555 git(dir.path(), &["add", "b.txt"]);
1556 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1557
1558 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
1559 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
1560
1561 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
1562 assert_eq!(two.len(), 2, "limit=10 must return both commits");
1563 }
1564
1565 #[test]
1568 fn list_refs_returns_main_branch() {
1569 let src = tempdir().unwrap();
1570 make_repo(src.path());
1571
1572 let dest_root = tempdir().unwrap();
1574 let dest = dest_root.path().join("clone");
1575 let src_str = src.path().to_str().unwrap();
1576 let dest_str = dest.to_str().unwrap();
1577 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1578
1579 let refs = list_refs(&dest).unwrap();
1580 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
1581 assert!(
1582 branch_names.contains(&"main"),
1583 "branches must include 'main', got: {branch_names:?}"
1584 );
1585 }
1586
1587 #[test]
1588 fn list_refs_returns_tag() {
1589 let src = tempdir().unwrap();
1590 make_repo(src.path());
1591 git(src.path(), &["tag", "v1.0.0"]);
1592
1593 let dest_root = tempdir().unwrap();
1594 let dest = dest_root.path().join("clone");
1595 let src_str = src.path().to_str().unwrap();
1596 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
1597 run_git(&dest, &["fetch", "--tags"]).unwrap();
1599
1600 let refs = list_refs(&dest).unwrap();
1601 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
1602 assert!(
1603 tag_names.contains(&"v1.0.0"),
1604 "tags must include 'v1.0.0', got: {tag_names:?}"
1605 );
1606 }
1607
1608 #[test]
1611 fn create_and_destroy_worktree() {
1612 let repo = tempdir().unwrap();
1613 make_repo(repo.path());
1614
1615 let sha = get_sha(repo.path(), "HEAD").unwrap();
1616
1617 let wt_root = tempdir().unwrap();
1618 let wt_path = wt_root.path().join("worktree");
1619
1620 create_worktree(repo.path(), &sha, &wt_path).unwrap();
1621 assert!(
1622 wt_path.exists(),
1623 "worktree directory must exist after creation"
1624 );
1625 assert!(
1626 wt_path.join("hello.txt").exists(),
1627 "worktree must contain committed files"
1628 );
1629
1630 destroy_worktree(repo.path(), &wt_path).unwrap();
1631 assert!(
1632 !wt_path.exists(),
1633 "worktree directory must be removed after destroy"
1634 );
1635 }
1636
1637 #[test]
1638 fn destroy_worktree_on_nonexistent_path_succeeds() {
1639 let repo = tempdir().unwrap();
1641 make_repo(repo.path());
1642 let nonexistent = repo.path().join("does_not_exist");
1643 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
1644 }
1645
1646 #[test]
1647 fn create_worktree_resolves_non_default_remote_branch() {
1648 let src = tempdir().unwrap();
1652 let inner = src.path().join("inner");
1653 std::fs::create_dir_all(&inner).unwrap();
1654 make_repo(&inner);
1655 git(&inner, &["checkout", "-b", "feature-x"]);
1656 std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
1657 git(&inner, &["add", "feat.txt"]);
1658 git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
1659 git(&inner, &["checkout", "main"]);
1660
1661 let dest_root = tempdir().unwrap();
1662 let dest = dest_root.path().join("clone");
1663 run_git(
1664 src.path(),
1665 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
1666 )
1667 .unwrap();
1668
1669 let wt_root = tempdir().unwrap();
1671 let wt = wt_root.path().join("wt");
1672 create_worktree(&dest, "feature-x", &wt).unwrap();
1673 assert!(
1674 wt.join("feat.txt").exists(),
1675 "worktree must contain the feature branch's file"
1676 );
1677 destroy_worktree(&dest, &wt).unwrap();
1678 }
1679
1680 #[test]
1681 fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
1682 let src = tempdir().unwrap();
1683 let inner = src.path().join("inner");
1684 std::fs::create_dir_all(&inner).unwrap();
1685 make_repo(&inner);
1686 git(&inner, &["branch", "release-1"]);
1687
1688 let dest_root = tempdir().unwrap();
1689 let dest = dest_root.path().join("clone");
1690 run_git(
1691 src.path(),
1692 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
1693 )
1694 .unwrap();
1695
1696 let sha = resolve_committish(&dest, "release-1").unwrap();
1698 assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
1699 assert!(resolve_committish(&dest, "no-such-branch").is_err());
1701 }
1702}