1use std::io::Read as _;
5use std::path::{Path, PathBuf};
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
68enum GitCredential {
76 Https { user: String, token: String },
78 Ssh { key_path: String },
80}
81
82fn hostkey(host: &str) -> String {
90 host.chars()
91 .map(|c| {
92 if c.is_ascii_alphanumeric() {
93 c.to_ascii_uppercase()
94 } else {
95 '_'
96 }
97 })
98 .collect()
99}
100
101fn resolve_credential(host: &str, port: Option<u16>) -> Option<GitCredential> {
112 let mut keys: Vec<String> = Vec::with_capacity(2);
117 if let Some(pt) = port {
118 keys.push(hostkey(&format!("{host}:{pt}")));
119 }
120 keys.push(hostkey(host));
121 for key in &keys {
122 if let Ok(v) = std::env::var(format!("SLOC_GIT_CRED_{key}"))
123 && let Some((user, token)) = v.split_once(':')
124 && !token.is_empty()
125 {
126 return Some(GitCredential::Https {
127 user: user.to_owned(),
128 token: token.to_owned(),
129 });
130 }
131 if let Ok(p) = std::env::var(format!("SLOC_GIT_SSHKEY_{key}"))
132 && !p.trim().is_empty()
133 {
134 return Some(GitCredential::Ssh { key_path: p });
135 }
136 }
137 cred_from_file(host)
138}
139
140fn cred_from_file(host: &str) -> Option<GitCredential> {
147 let path = std::env::var("SLOC_GIT_CRED_FILE").ok()?;
148 let path = path.trim();
149 if path.is_empty() {
150 return None;
151 }
152 warn_if_world_readable(path);
153 let content = std::fs::read_to_string(path).ok()?;
154 let host_lower = host.to_lowercase();
155 for line in content.lines() {
156 let line = line.trim();
157 if line.is_empty() || line.starts_with('#') {
158 continue;
159 }
160 let Some((k, v)) = line.split_once('=') else {
161 continue;
162 };
163 if k.trim().trim_matches('"').to_lowercase() != host_lower {
164 continue;
165 }
166 let v = v.trim().trim_matches('"');
167 if let Some((user, token)) = v.split_once(':')
168 && !token.is_empty()
169 {
170 return Some(GitCredential::Https {
171 user: user.to_owned(),
172 token: token.to_owned(),
173 });
174 }
175 }
176 None
177}
178
179#[cfg(unix)]
181fn warn_if_world_readable(path: &str) {
182 use std::os::unix::fs::PermissionsExt as _;
183 if let Ok(meta) = std::fs::metadata(path)
184 && meta.permissions().mode() & 0o077 != 0
185 {
186 eprintln!(
187 "warning: SLOC_GIT_CRED_FILE {path:?} is group/world-readable; \
188 restrict it with chmod 600"
189 );
190 }
191}
192
193#[cfg(not(unix))]
194fn warn_if_world_readable(_path: &str) {}
195
196#[derive(Default)]
205struct CredInjection {
206 config: Vec<String>,
207 env: Vec<(String, String)>,
208}
209
210fn cred_injection(host: &str, port: Option<u16>) -> CredInjection {
211 match resolve_credential(host, port) {
212 Some(GitCredential::Https { user, token }) => CredInjection {
213 config: vec![
214 "credential.helper=".to_owned(),
215 "credential.helper=!f() { test \"$1\" = get && echo \"username=$GIT_U\" && \
216 echo \"password=$GIT_P\"; }; f"
217 .to_owned(),
218 ],
219 env: vec![("GIT_U".to_owned(), user), ("GIT_P".to_owned(), token)],
220 },
221 Some(GitCredential::Ssh { key_path }) => {
222 let mut ssh = format!("ssh -i \"{key_path}\" -o IdentitiesOnly=yes -o BatchMode=yes");
223 if ssh_accept_new() {
227 ssh.push_str(" -o StrictHostKeyChecking=accept-new");
228 }
229 CredInjection {
230 config: Vec::new(),
231 env: vec![("GIT_SSH_COMMAND".to_owned(), ssh)],
232 }
233 }
234 None => CredInjection::default(),
235 }
236}
237
238fn ssh_accept_new() -> bool {
243 std::env::var("SLOC_GIT_SSH_ACCEPT_NEW")
244 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
245}
246
247fn allow_local() -> bool {
253 std::env::var("SLOC_GIT_ALLOW_LOCAL").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
254}
255
256fn local_root() -> Option<PathBuf> {
260 std::env::var("SLOC_GIT_LOCAL_ROOT")
261 .ok()
262 .map(PathBuf::from)
263 .filter(|p| !p.as_os_str().is_empty())
264}
265
266fn network_git_config() -> Vec<String> {
281 let mut cfg = vec![
282 "http.followRedirects=false".to_owned(),
283 "http.lowSpeedLimit=1000".to_owned(),
284 "http.lowSpeedTime=30".to_owned(),
285 ];
286 if cfg!(windows) {
287 cfg.push("http.sslBackend=schannel".to_owned());
288 }
289 if ssl_no_verify() {
290 cfg.push("http.sslVerify=false".to_owned());
291 }
292 cfg
293}
294
295fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
297 let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
298 for c in cfg {
299 v.push("-c");
300 v.push(c.as_str());
301 }
302 v.extend_from_slice(tail);
303 v
304}
305
306fn persist_repo_config(dest: &Path, cfg: &[String]) {
313 let mut helper_reset = false;
314 for kv in cfg {
315 if let Some((key, value)) = kv.split_once('=') {
316 if key == "credential.helper" {
317 if !helper_reset {
324 let _ = run_git(dest, &["config", "--unset-all", "credential.helper"]);
325 helper_reset = true;
326 }
327 let _ = run_git(dest, &["config", "--add", "credential.helper", value]);
328 } else {
329 let _ = run_git(dest, &["config", key, value]);
330 }
331 }
332 }
333}
334
335fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
338 run_git_env(repo, args, &[])
339}
340
341fn run_git_env(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Result<String> {
346 let mut cmd = std::process::Command::new("git");
347 cmd.env("GIT_TERMINAL_PROMPT", "0")
356 .env("GCM_INTERACTIVE", "never")
357 .env("GIT_ASKPASS", "")
358 .env("SSH_ASKPASS", "");
359 for (k, v) in extra_env {
362 cmd.env(k, v);
363 }
364 cmd.args(args)
365 .current_dir(repo)
366 .stdin(Stdio::null())
367 .stdout(Stdio::piped())
368 .stderr(Stdio::piped());
369 let mut child = cmd.spawn().context("failed to spawn git process")?;
370
371 let mut out_pipe = child.stdout.take();
375 let mut err_pipe = child.stderr.take();
376 let out_handle = std::thread::spawn(move || {
377 let mut buf = Vec::new();
378 if let Some(p) = out_pipe.as_mut() {
379 let _ = p.read_to_end(&mut buf);
380 }
381 buf
382 });
383 let err_handle = std::thread::spawn(move || {
384 let mut buf = Vec::new();
385 if let Some(p) = err_pipe.as_mut() {
386 let _ = p.read_to_end(&mut buf);
387 }
388 buf
389 });
390
391 let timeout = git_timeout();
393 let start = Instant::now();
394 let status = loop {
395 if let Some(status) = child.try_wait().context("failed to poll git process")? {
396 break status;
397 }
398 if start.elapsed() >= timeout {
399 let _ = child.kill();
400 let _ = child.wait();
401 bail!(
402 "git {} timed out after {}s — the remote did not respond in time. \
403 On a corporate network this usually means a proxy or VPN is slow or \
404 blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
405 or check your proxy/VPN configuration.",
406 args.first().copied().unwrap_or(""),
407 timeout.as_secs()
408 );
409 }
410 std::thread::sleep(Duration::from_millis(100));
411 };
412
413 let stdout = out_handle.join().unwrap_or_default();
414 let stderr = err_handle.join().unwrap_or_default();
415 if !status.success() {
416 let stderr = String::from_utf8_lossy(&stderr);
417 bail!(
418 "git {}: {}",
419 args.first().copied().unwrap_or(""),
420 stderr.trim()
421 );
422 }
423 Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
424}
425
426#[must_use]
435pub fn normalize_git_url(raw: &str) -> String {
436 let url = raw.trim();
437 if url.starts_with("git@") || url.starts_with("ssh://") {
438 return url.to_owned();
439 }
440 let scheme = if url.starts_with("https://") {
441 "https"
442 } else if url.starts_with("http://") {
443 "http"
444 } else {
445 return url.to_owned();
446 };
447 let authority_and_path = &url[scheme.len() + 3..];
448 let (host, path) = authority_and_path
449 .find('/')
450 .map_or((authority_and_path, "/"), |i| {
451 (&authority_and_path[..i], &authority_and_path[i..])
452 });
453 let path = path.trim_end_matches('/');
454
455 try_normalize_bitbucket_server(scheme, host, path)
456 .or_else(|| try_normalize_gitlab(scheme, host, path))
457 .or_else(|| try_normalize_github(scheme, host, path))
458 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
459 .unwrap_or_else(|| url.to_owned())
460}
461
462fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
466 let path_lower = path.to_lowercase();
467 let proj_pos = path_lower.find("/projects/")?;
468 let after = &path[proj_pos + "/projects/".len()..];
469 let parts: Vec<&str> = after.splitn(4, '/').collect();
470 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
471 return None;
472 }
473 let context = &path[..proj_pos];
474 let project = parts[0].to_lowercase();
475 let repo = parts[2].trim_end_matches(".git");
476 Some(format!(
477 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
478 ))
479}
480
481fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
484 let idx = path.find("/-/")?;
485 let repo_path = path[..idx].trim_end_matches(".git");
486 Some(format!("{scheme}://{host}{repo_path}.git"))
487}
488
489fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
492 if host != "github.com" && !host.ends_with(".github.com") {
493 return None;
494 }
495 let p = path.trim_start_matches('/');
496 let parts: Vec<&str> = p.splitn(4, '/').collect();
497 if parts.len() < 3
498 || !matches!(
499 parts[2],
500 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
501 )
502 {
503 return None;
504 }
505 let owner = parts[0];
506 let repo = parts[1].trim_end_matches(".git");
507 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
508}
509
510fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
513 if host != "bitbucket.org" {
514 return None;
515 }
516 let p = path.trim_start_matches('/');
517 let parts: Vec<&str> = p.splitn(4, '/').collect();
518 if parts.len() < 3 || parts[2] != "src" {
519 return None;
520 }
521 let ws = parts[0];
522 let repo = parts[1].trim_end_matches(".git");
523 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
524}
525
526fn validate_clone_url(url: &str) -> Result<()> {
529 let lower = url.to_lowercase();
530 let allowed = ["https://", "git://", "ssh://", "git@"];
533 if !allowed.iter().any(|p| lower.starts_with(p)) {
534 bail!(
535 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
536 permitted (got {url:?})"
537 );
538 }
539 let Some(host) = host_of_git_url(url) else {
546 return Ok(());
547 };
548 check_host_allowed(&host)?;
549 check_resolved_ips(&host, url)?;
550 Ok(())
551}
552
553fn check_host_allowed(host: &str) -> Result<()> {
557 let allow = git_host_allowlist();
563 if allow.is_empty() {
564 if require_host_allowlist() {
565 bail!(
566 "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
567 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
568 );
569 }
570 } else if !allow.iter().any(|h| h == host) {
571 bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
572 }
573 if is_ssrf_blocked_host(host) {
574 bail!(
575 "git URL rejected: loopback, link-local, and cloud-metadata \
576 addresses are not permitted (host {host:?})"
577 );
578 }
579 Ok(())
580}
581
582fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
588 let Some(port) = port_of_git_url(url) else {
589 return Ok(());
590 };
591 let Ok(addrs) = resolve_host_port(host, port) else {
592 return Ok(());
593 };
594 for addr in addrs {
595 if is_ssrf_blocked_ip(addr.ip()) {
596 bail!(
597 "git URL rejected: host {host:?} resolves to a blocked \
598 address {} (loopback/link-local/cloud-metadata)",
599 addr.ip()
600 );
601 }
602 }
603 Ok(())
604}
605
606#[cfg(not(test))]
613fn resolve_host_port(
614 host: &str,
615 port: u16,
616) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
617 use std::net::ToSocketAddrs as _;
618 (host, port).to_socket_addrs()
619}
620
621#[cfg(test)]
622fn resolve_host_port(
623 host: &str,
624 port: u16,
625) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
626 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
627 let ip = host
631 .parse::<IpAddr>()
632 .unwrap_or(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)));
633 Ok(vec![SocketAddr::new(ip, port)].into_iter())
634}
635
636fn host_of_git_url(url: &str) -> Option<String> {
639 let u = url.trim();
640 if let Some(rest) = u.strip_prefix("git@") {
642 let host = rest.split(':').next().unwrap_or(rest);
643 return Some(host.to_lowercase());
644 }
645 let after_scheme = u.split("://").nth(1)?;
647 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
648 let authority = authority.rsplit('@').next().unwrap_or(authority);
650 let host = authority.strip_prefix('[').map_or_else(
652 || authority.split(':').next().unwrap_or(authority).to_string(),
653 |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
654 );
655 Some(host.to_lowercase())
656}
657
658fn port_of_git_url(url: &str) -> Option<u16> {
662 let u = url.trim();
663 if u.starts_with("git@") {
665 return Some(22);
666 }
667 let (scheme, after_scheme) = u.split_once("://")?;
668 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
669 let authority = authority.rsplit('@').next().unwrap_or(authority);
670 let explicit = authority.strip_prefix('[').map_or_else(
672 || {
674 authority
675 .rsplit_once(':')
676 .and_then(|(_, p)| p.parse::<u16>().ok())
677 },
678 |stripped| {
680 stripped
681 .split_once("]:")
682 .and_then(|(_, p)| p.parse::<u16>().ok())
683 },
684 );
685 explicit.or_else(|| match scheme.to_lowercase().as_str() {
686 "https" => Some(443),
687 "git" => Some(9418),
688 "ssh" => Some(22),
689 _ => None,
690 })
691}
692
693fn explicit_port_of_git_url(url: &str) -> Option<u16> {
699 let u = url.trim();
700 if u.starts_with("git@") {
701 return None;
702 }
703 let (_scheme, after_scheme) = u.split_once("://")?;
704 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
705 let authority = authority.rsplit('@').next().unwrap_or(authority);
706 authority.strip_prefix('[').map_or_else(
707 || {
708 authority
709 .rsplit_once(':')
710 .and_then(|(_, p)| p.parse::<u16>().ok())
711 },
712 |stripped| {
713 stripped
714 .split_once("]:")
715 .and_then(|(_, p)| p.parse::<u16>().ok())
716 },
717 )
718}
719
720const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
722 "metadata.google.internal",
723 "metadata.internal",
724 "instance-data",
725];
726
727fn is_ssrf_blocked_host(host: &str) -> bool {
731 let h = host
732 .trim()
733 .trim_start_matches('[')
734 .trim_end_matches(']')
735 .to_lowercase();
736 if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
737 return true;
738 }
739 h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
740}
741
742fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
745 match ip {
746 std::net::IpAddr::V4(v4) => {
747 v4.is_loopback()
748 || v4.is_link_local()
749 || v4.is_unspecified()
750 || v4.is_broadcast()
751 || v4.is_multicast()
752 || v4.octets() == [100, 100, 100, 200] }
754 std::net::IpAddr::V6(v6) => {
755 v6.is_loopback()
756 || v6.is_unspecified()
757 || v6.is_multicast()
758 || (v6.segments()[0] & 0xffc0) == 0xfe80 }
760 }
761}
762
763enum GitSource {
766 Remote,
768 FileUrl,
770 LocalPath,
773 Bundle,
775}
776
777fn classify_source(url: &str) -> GitSource {
779 let u = url.trim();
780 let lower = u.to_lowercase();
781 if lower.starts_with("https://")
782 || lower.starts_with("http://")
783 || lower.starts_with("git://")
784 || lower.starts_with("ssh://")
785 || u.starts_with("git@")
786 {
787 GitSource::Remote
788 } else if lower.starts_with("file://") {
789 GitSource::FileUrl
790 } else if lower.ends_with(".bundle") {
791 GitSource::Bundle
792 } else {
793 GitSource::LocalPath
794 }
795}
796
797pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
809 let normalized = normalize_git_url(url);
810 let url = normalized.as_str();
811 match classify_source(url) {
812 GitSource::Remote => clone_or_fetch_remote(url, dest),
813 source => clone_or_fetch_local(url, dest, &source),
814 }
815}
816
817fn clone_or_fetch_remote(url: &str, dest: &Path) -> Result<()> {
820 validate_clone_url(url)?;
821 let mut cfg = network_git_config();
825 let inj = host_of_git_url(url)
827 .map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
828 .unwrap_or_default();
829 cfg.extend(inj.config.iter().cloned());
830 let env: Vec<(&str, &str)> = inj
831 .env
832 .iter()
833 .map(|(k, v)| (k.as_str(), v.as_str()))
834 .collect();
835
836 if dest.join(".git").exists() {
837 let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
838 run_git_env(dest, &args, &env)?;
839 return Ok(());
840 }
841
842 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
843 let dest_str = dest.to_str().unwrap_or(".");
844 let parent = dest.parent().unwrap_or(dest);
845
846 let fast = with_config(
852 &cfg,
853 &[
854 "clone",
855 "--filter=blob:none",
856 "--no-checkout",
857 "--no-single-branch",
858 url,
859 dest_str,
860 ],
861 );
862 if let Err(e) = run_git_env(parent, &fast, &env) {
863 let msg = e.to_string().to_lowercase();
869 if !(msg.contains("filter") || msg.contains("partial")) {
870 return Err(e);
871 }
872 let _ = std::fs::remove_dir_all(dest);
873 std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
874 let full = with_config(
875 &cfg,
876 &[
877 "clone",
878 "--no-checkout",
879 "--no-single-branch",
880 url,
881 dest_str,
882 ],
883 );
884 run_git_env(parent, &full, &env)?;
885 }
886 persist_repo_config(dest, &cfg);
887 Ok(())
888}
889
890fn clone_or_fetch_local(url: &str, dest: &Path, source: &GitSource) -> Result<()> {
894 let src = validate_local_source(url, source)?;
895 let cfg = network_git_config();
896 if dest.join(".git").exists() {
897 let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
898 run_git(dest, &args)?;
899 return Ok(());
900 }
901 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
902 let dest_str = dest.to_str().unwrap_or(".");
903 let parent = dest.parent().unwrap_or(dest);
904
905 let tail: Vec<&str> = match source {
906 GitSource::Bundle => vec!["clone", "--no-checkout", &src, dest_str],
911 GitSource::FileUrl => vec![
913 "clone",
914 "--filter=blob:none",
915 "--no-checkout",
916 "--no-single-branch",
917 &src,
918 dest_str,
919 ],
920 GitSource::LocalPath => vec![
924 "clone",
925 "--no-local",
926 "--filter=blob:none",
927 "--no-checkout",
928 "--no-single-branch",
929 &src,
930 dest_str,
931 ],
932 GitSource::Remote => unreachable!("remote sources are handled by clone_or_fetch_remote"),
933 };
934 let args = with_config(&cfg, &tail);
935 run_git(parent, &args)?;
936 persist_repo_config(dest, &cfg);
937 Ok(())
938}
939
940fn validate_local_source(url: &str, source: &GitSource) -> Result<String> {
945 if !allow_local() {
946 bail!(
947 "local/offline git source rejected: set SLOC_GIT_ALLOW_LOCAL=1 to enable bundle / \
948 file:// / local-path imports (got {url:?})"
949 );
950 }
951 let Some(root) = local_root() else {
952 bail!(
953 "SLOC_GIT_ALLOW_LOCAL is set but SLOC_GIT_LOCAL_ROOT is not — refusing local import \
954 (fail-closed). Point SLOC_GIT_LOCAL_ROOT at the directory holding your bundles/mirrors."
955 );
956 };
957
958 let raw = url.trim();
959 let path = match source {
960 GitSource::FileUrl => file_url_to_path(raw)?,
961 _ => raw.to_owned(),
962 };
963 if path.starts_with("\\\\") || path.starts_with("//") {
966 bail!("UNC path rejected: SMB shares are network sources, not local ({url:?})");
967 }
968
969 let canon = std::fs::canonicalize(&path)
970 .with_context(|| format!("local git source not found or unreadable: {path:?}"))?;
971 let root_canon = std::fs::canonicalize(&root)
972 .with_context(|| format!("SLOC_GIT_LOCAL_ROOT not found: {}", root.display()))?;
973 if !canon.starts_with(&root_canon) {
974 bail!(
975 "local git source {} is outside SLOC_GIT_LOCAL_ROOT {}",
976 canon.display(),
977 root_canon.display()
978 );
979 }
980 Ok(deverbatim(&canon))
982}
983
984fn file_url_to_path(url: &str) -> Result<String> {
988 let rest = &url.trim()[7..]; if !rest.starts_with('/') {
990 bail!(
991 "file:// URL with a host authority is not permitted (use file:///local/path): {url:?}"
992 );
993 }
994 let after = &rest[1..];
997 if after.len() >= 2 && after.as_bytes()[1] == b':' {
998 Ok(after.to_owned())
999 } else {
1000 Ok(rest.to_owned())
1001 }
1002}
1003
1004fn deverbatim(p: &Path) -> String {
1007 let s = p.to_string_lossy();
1008 s.strip_prefix(r"\\?\").unwrap_or(&s).to_owned()
1009}
1010
1011pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
1016 run_git(repo, &["rev-parse", ref_name])
1017}
1018
1019pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
1032 let candidates = [
1033 ref_name.to_owned(),
1034 format!("origin/{ref_name}"),
1035 format!("refs/remotes/origin/{ref_name}"),
1036 ];
1037 for cand in &candidates {
1038 let spec = format!("{cand}^{{commit}}");
1039 if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
1040 && !sha.is_empty()
1041 {
1042 return Ok(sha);
1043 }
1044 }
1045 bail!(
1046 "ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
1047 and as refs/remotes/origin/{ref_name})"
1048 );
1049}
1050
1051pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
1060 let wt = worktree_path.to_str().unwrap_or(".");
1061 let committish = resolve_committish(repo, ref_name)?;
1062 let env = cred_env_for_repo(repo);
1067 let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1068 run_git_env(
1069 repo,
1070 &["worktree", "add", "--detach", wt, &committish],
1071 &env_refs,
1072 )?;
1073 Ok(())
1074}
1075
1076fn cred_env_for_repo(repo: &Path) -> Vec<(String, String)> {
1080 let Ok(url) = run_git(repo, &["config", "--get", "remote.origin.url"]) else {
1081 return Vec::new();
1082 };
1083 let Some(host) = host_of_git_url(&url) else {
1084 return Vec::new();
1085 };
1086 cred_injection(&host, explicit_port_of_git_url(&url)).env
1087}
1088
1089pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
1094 let wt = worktree_path.to_str().unwrap_or(".");
1095 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
1096 Ok(())
1097}
1098
1099pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
1106 Ok(RepoRefs {
1107 branches: list_branches(repo)?,
1108 tags: list_tags(repo)?,
1109 recent_commits: list_commits(repo, "HEAD", 40)?,
1110 })
1111}
1112
1113fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
1114 let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1120 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
1124 let refs = out
1125 .lines()
1126 .filter(|l| !l.trim().is_empty())
1127 .filter_map(|l| {
1129 let (symref, rest) = l.split_once('|')?;
1130 if symref.trim().is_empty() {
1131 Some(rest)
1132 } else {
1133 None
1134 }
1135 })
1136 .map(|l| parse_ref_line(l, GitRefKind::Branch))
1137 .map(|mut r| {
1138 if let Some(slash) = r.name.find('/') {
1140 r.name = r.name[slash + 1..].to_owned();
1141 }
1142 r
1143 })
1144 .collect::<Vec<_>>();
1145 Ok(refs)
1146}
1147
1148fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
1149 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1150 let out = run_git(
1151 repo,
1152 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
1153 )?;
1154 Ok(out
1155 .lines()
1156 .filter(|l| !l.trim().is_empty())
1157 .map(|l| parse_ref_line(l, GitRefKind::Tag))
1158 .collect())
1159}
1160
1161fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
1162 let parts: Vec<&str> = line.splitn(4, '|').collect();
1163 let name = parts.first().copied().unwrap_or("").to_owned();
1164 let sha = parts.get(1).copied().unwrap_or("").to_owned();
1165 let date = parts.get(2).copied().and_then(parse_git_date);
1166 let message = parts.get(3).map(|s| (*s).to_owned());
1167 GitRef {
1168 kind,
1169 name,
1170 sha,
1171 date,
1172 message,
1173 }
1174}
1175
1176pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
1183 let fmt = "%H|%h|%an|%aI|%s";
1184 let n = format!("-{limit}");
1185 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
1186 Ok(out
1187 .lines()
1188 .filter(|l| !l.trim().is_empty())
1189 .map(parse_commit_line)
1190 .collect())
1191}
1192
1193fn parse_commit_line(line: &str) -> GitCommit {
1194 let p: Vec<&str> = line.splitn(5, '|').collect();
1195 let sha = p.first().copied().unwrap_or("").to_owned();
1196 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
1197 let author = p.get(2).copied().unwrap_or("").to_owned();
1198 let date = p
1199 .get(3)
1200 .copied()
1201 .and_then(parse_git_date)
1202 .unwrap_or_default();
1203 let subject = p.get(4).copied().unwrap_or("").to_owned();
1204 GitCommit {
1205 sha,
1206 short_sha,
1207 author,
1208 date,
1209 subject,
1210 }
1211}
1212
1213fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
1214 chrono::DateTime::parse_from_rfc3339(s)
1215 .ok()
1216 .map(|d| d.with_timezone(&chrono::Utc))
1217}
1218
1219#[cfg(test)]
1223static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1224
1225#[cfg(test)]
1228fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1229 ENV_LOCK
1230 .lock()
1231 .unwrap_or_else(std::sync::PoisonError::into_inner)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237 use crate::GitRefKind;
1238 use chrono::Timelike as _;
1239
1240 #[test]
1243 fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
1244 assert!(is_ssrf_blocked_host("localhost"));
1245 assert!(is_ssrf_blocked_host("metadata.google.internal"));
1246 assert!(is_ssrf_blocked_host("metadata.internal"));
1247 assert!(is_ssrf_blocked_host("instance-data"));
1248 assert!(is_ssrf_blocked_host(" LOCALHOST "));
1250 assert!(is_ssrf_blocked_host("127.0.0.1"));
1252 assert!(is_ssrf_blocked_host("[::1]"));
1253 assert!(is_ssrf_blocked_host("169.254.169.254"));
1254 }
1255
1256 #[test]
1257 fn require_host_allowlist_defaults_false() {
1258 assert!(!require_host_allowlist());
1260 }
1261
1262 #[test]
1263 fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
1264 assert!(check_host_allowed("github.com").is_ok());
1266 assert!(check_host_allowed("localhost").is_err());
1267 }
1268
1269 #[test]
1270 fn is_ssrf_blocked_host_allows_public_hosts() {
1271 assert!(!is_ssrf_blocked_host("github.com"));
1272 assert!(!is_ssrf_blocked_host("example.com"));
1273 assert!(!is_ssrf_blocked_host("192.168.1.10"));
1275 assert!(!is_ssrf_blocked_host("10.0.0.1"));
1276 }
1277
1278 #[test]
1281 fn network_git_config_always_hardens_redirects_and_lowspeed() {
1282 let cfg = network_git_config();
1283 assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
1284 assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
1285 assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
1286 }
1287
1288 #[cfg(windows)]
1289 #[test]
1290 fn network_git_config_uses_schannel_on_windows() {
1291 let cfg = network_git_config();
1294 assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
1295 }
1296
1297 #[test]
1298 fn with_config_interleaves_dash_c_pairs_before_tail() {
1299 let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
1300 let args = with_config(&cfg, &["clone", "url", "dest"]);
1301 assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
1302 }
1303
1304 #[test]
1305 fn with_config_empty_cfg_is_just_the_tail() {
1306 let cfg: Vec<String> = Vec::new();
1307 assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
1308 }
1309
1310 #[test]
1311 fn git_timeout_is_positive() {
1312 assert!(git_timeout().as_secs() > 0);
1314 }
1315
1316 #[test]
1319 fn normalize_github_tree_url() {
1320 assert_eq!(
1321 normalize_git_url("https://github.com/owner/repo/tree/main"),
1322 "https://github.com/owner/repo.git"
1323 );
1324 }
1325
1326 #[test]
1327 fn normalize_github_blob_url() {
1328 assert_eq!(
1329 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
1330 "https://github.com/owner/repo.git"
1331 );
1332 }
1333
1334 #[test]
1335 fn normalize_github_commits_url() {
1336 assert_eq!(
1337 normalize_git_url("https://github.com/owner/repo/commits/main"),
1338 "https://github.com/owner/repo.git"
1339 );
1340 }
1341
1342 #[test]
1343 fn normalize_github_releases_url() {
1344 assert_eq!(
1345 normalize_git_url("https://github.com/owner/repo/releases"),
1346 "https://github.com/owner/repo.git"
1347 );
1348 }
1349
1350 #[test]
1351 fn normalize_github_tags_url() {
1352 assert_eq!(
1353 normalize_git_url("https://github.com/owner/repo/tags"),
1354 "https://github.com/owner/repo.git"
1355 );
1356 }
1357
1358 #[test]
1359 fn normalize_github_branches_url() {
1360 assert_eq!(
1361 normalize_git_url("https://github.com/owner/repo/branches"),
1362 "https://github.com/owner/repo.git"
1363 );
1364 }
1365
1366 #[test]
1367 fn normalize_github_plain_clone_url_unchanged() {
1368 let url = "https://github.com/owner/repo.git";
1369 assert_eq!(normalize_git_url(url), url);
1370 }
1371
1372 #[test]
1373 fn normalize_gitlab_tree_url() {
1374 assert_eq!(
1375 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
1376 "https://gitlab.com/group/subgroup/repo.git"
1377 );
1378 }
1379
1380 #[test]
1381 fn normalize_gitlab_blob_url() {
1382 assert_eq!(
1383 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
1384 "https://gitlab.com/org/repo.git"
1385 );
1386 }
1387
1388 #[test]
1389 fn normalize_gitlab_self_hosted() {
1390 assert_eq!(
1391 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
1392 "https://gitlab.corp.com/team/project.git"
1393 );
1394 }
1395
1396 #[test]
1397 fn normalize_bitbucket_server_browse_url() {
1398 assert_eq!(
1399 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
1400 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
1401 );
1402 }
1403
1404 #[test]
1405 fn normalize_bitbucket_server_with_context() {
1406 assert_eq!(
1407 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
1408 "https://host.com/ctx/scm/proj/repo.git"
1409 );
1410 }
1411
1412 #[test]
1413 fn normalize_bitbucket_cloud_src_url() {
1414 assert_eq!(
1415 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
1416 "https://bitbucket.org/workspace/repo.git"
1417 );
1418 }
1419
1420 #[test]
1421 fn normalize_ssh_url_unchanged() {
1422 let url = "git@github.com:owner/repo.git";
1423 assert_eq!(normalize_git_url(url), url);
1424 }
1425
1426 #[test]
1427 fn normalize_ssh_protocol_url_unchanged() {
1428 let url = "ssh://git@github.com/owner/repo.git";
1429 assert_eq!(normalize_git_url(url), url);
1430 }
1431
1432 #[test]
1433 fn normalize_trims_leading_trailing_whitespace() {
1434 assert_eq!(
1435 normalize_git_url(" https://github.com/owner/repo/tree/main "),
1436 "https://github.com/owner/repo.git"
1437 );
1438 }
1439
1440 #[test]
1441 fn normalize_http_url_without_match_returned_unchanged() {
1442 let url = "http://internal.corp.com/repo.git";
1443 assert_eq!(normalize_git_url(url), url);
1444 }
1445
1446 #[test]
1449 fn validate_https_url_ok() {
1450 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
1451 }
1452
1453 #[test]
1454 fn validate_git_protocol_url_ok() {
1455 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
1456 }
1457
1458 #[test]
1459 fn validate_ssh_protocol_url_ok() {
1460 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
1461 }
1462
1463 #[test]
1464 fn validate_git_at_url_ok() {
1465 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
1466 }
1467
1468 #[test]
1469 fn validate_http_plain_rejected() {
1470 assert!(
1471 validate_clone_url("http://github.com/owner/repo.git").is_err(),
1472 "plain http:// must be rejected"
1473 );
1474 }
1475
1476 #[test]
1477 fn validate_link_local_169_254_rejected() {
1478 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1479 }
1480
1481 #[test]
1482 fn validate_google_metadata_endpoint_rejected() {
1483 assert!(
1484 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
1485 );
1486 }
1487
1488 #[test]
1489 fn validate_alibaba_metadata_rejected() {
1490 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
1491 }
1492
1493 #[test]
1494 fn validate_ipv6_fe80_link_local_rejected() {
1495 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
1496 }
1497
1498 #[test]
1499 fn validate_file_protocol_rejected() {
1500 assert!(validate_clone_url("file:///etc/passwd").is_err());
1501 }
1502
1503 #[test]
1504 fn validate_empty_string_rejected() {
1505 assert!(validate_clone_url("").is_err());
1506 }
1507
1508 #[test]
1509 fn validate_rfc1918_10_allowed() {
1510 assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
1512 }
1513
1514 #[test]
1515 fn validate_rfc1918_192_168_allowed() {
1516 assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
1517 }
1518
1519 #[test]
1520 fn validate_rfc1918_172_16_allowed() {
1521 assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
1522 }
1523
1524 #[test]
1525 fn validate_rfc1918_172_31_allowed() {
1526 assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
1527 }
1528
1529 #[test]
1530 fn validate_ipv6_ula_fd_allowed() {
1531 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
1533 }
1534
1535 #[test]
1537 fn port_https_default() {
1538 assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
1539 }
1540
1541 #[test]
1542 fn port_explicit_overrides_default() {
1543 assert_eq!(
1544 port_of_git_url("https://gitlab.corp:8443/o/r.git"),
1545 Some(8443)
1546 );
1547 }
1548
1549 #[test]
1550 fn port_git_scheme_default() {
1551 assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
1552 }
1553
1554 #[test]
1555 fn port_scp_like_is_ssh() {
1556 assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
1557 }
1558
1559 #[test]
1560 fn port_ipv6_with_explicit_port() {
1561 assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
1562 }
1563
1564 #[test]
1565 fn port_ipv6_default() {
1566 assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
1567 }
1568
1569 #[test]
1570 fn validate_metadata_ip_literal_still_rejected() {
1571 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1573 }
1574
1575 #[test]
1576 fn validate_loopback_127_rejected() {
1577 assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
1578 }
1579
1580 #[test]
1581 fn validate_localhost_rejected() {
1582 assert!(validate_clone_url("https://localhost/repo.git").is_err());
1583 }
1584
1585 #[test]
1586 fn validate_unspecified_0_0_0_0_rejected() {
1587 assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
1588 }
1589
1590 #[test]
1595 fn host_of_git_url_https_with_port_and_creds() {
1596 assert_eq!(
1597 host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
1598 Some("gitlab.corp.com")
1599 );
1600 }
1601
1602 #[test]
1603 fn host_of_git_url_scp_syntax() {
1604 assert_eq!(
1605 host_of_git_url("git@github.com:owner/repo.git").as_deref(),
1606 Some("github.com")
1607 );
1608 }
1609
1610 #[test]
1611 fn host_of_git_url_ipv6_literal() {
1612 assert_eq!(
1613 host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
1614 Some("fe80::1")
1615 );
1616 }
1617
1618 #[test]
1619 fn validate_clone_url_path_with_version_number_not_blocked() {
1620 assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
1622 assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
1623 }
1624
1625 #[test]
1628 fn bitbucket_server_uppercase_project_lowercased() {
1629 let r = try_normalize_bitbucket_server(
1630 "https",
1631 "bb.corp.com",
1632 "/projects/PROJ/repos/myrepo/browse",
1633 );
1634 assert_eq!(
1635 r,
1636 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
1637 );
1638 }
1639
1640 #[test]
1641 fn bitbucket_server_without_projects_returns_none() {
1642 assert!(
1643 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
1644 );
1645 }
1646
1647 #[test]
1648 fn bitbucket_server_missing_repos_segment_returns_none() {
1649 assert!(
1650 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
1651 .is_none()
1652 );
1653 }
1654
1655 #[test]
1658 fn gitlab_dash_tree_normalized() {
1659 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
1660 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1661 }
1662
1663 #[test]
1664 fn gitlab_no_dash_returns_none() {
1665 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
1666 }
1667
1668 #[test]
1669 fn gitlab_strips_existing_dot_git_before_readding() {
1670 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
1671 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1672 }
1673
1674 #[test]
1677 fn github_tree_normalized() {
1678 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
1679 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
1680 }
1681
1682 #[test]
1683 fn github_non_github_host_returns_none() {
1684 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
1685 }
1686
1687 #[test]
1688 fn github_plain_two_segment_path_returns_none() {
1689 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
1690 }
1691
1692 #[test]
1693 fn github_unknown_third_segment_returns_none() {
1694 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
1695 }
1696
1697 #[test]
1700 fn bitbucket_cloud_src_normalized() {
1701 let r = try_normalize_bitbucket_cloud(
1702 "https",
1703 "bitbucket.org",
1704 "/workspace/repo/src/main/README.md",
1705 );
1706 assert_eq!(
1707 r,
1708 Some("https://bitbucket.org/workspace/repo.git".to_owned())
1709 );
1710 }
1711
1712 #[test]
1713 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
1714 assert!(
1715 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
1716 );
1717 }
1718
1719 #[test]
1720 fn bitbucket_cloud_without_src_segment_returns_none() {
1721 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
1722 }
1723
1724 #[test]
1727 fn parse_ref_line_all_fields() {
1728 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
1729 let r = parse_ref_line(line, GitRefKind::Branch);
1730 assert_eq!(r.name, "main");
1731 assert_eq!(r.sha, "abc1234");
1732 assert!(r.date.is_some());
1733 assert_eq!(r.message.as_deref(), Some("Initial commit"));
1734 assert!(matches!(r.kind, GitRefKind::Branch));
1735 }
1736
1737 #[test]
1738 fn parse_ref_line_tag_kind() {
1739 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
1740 let r = parse_ref_line(line, GitRefKind::Tag);
1741 assert_eq!(r.name, "v1.0.0");
1742 assert!(matches!(r.kind, GitRefKind::Tag));
1743 }
1744
1745 #[test]
1746 fn parse_ref_line_name_only() {
1747 let r = parse_ref_line("main", GitRefKind::Branch);
1748 assert_eq!(r.name, "main");
1749 assert_eq!(r.sha, "");
1750 assert!(r.date.is_none());
1751 assert!(r.message.is_none());
1752 }
1753
1754 #[test]
1755 fn parse_ref_line_invalid_date_gives_none() {
1756 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
1757 assert!(r.date.is_none());
1758 assert_eq!(r.message.as_deref(), Some("msg"));
1759 }
1760
1761 #[test]
1762 fn parse_ref_line_empty_string() {
1763 let r = parse_ref_line("", GitRefKind::Branch);
1764 assert_eq!(r.name, "");
1765 }
1766
1767 #[test]
1770 fn parse_commit_line_all_fields() {
1771 let line =
1772 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
1773 let c = parse_commit_line(line);
1774 assert_eq!(c.sha, "abc1234567890abcdef");
1775 assert_eq!(c.short_sha, "abc1234");
1776 assert_eq!(c.author, "Alice Smith");
1777 assert_eq!(c.subject, "Fix critical bug");
1778 }
1779
1780 #[test]
1781 fn parse_commit_line_empty() {
1782 let c = parse_commit_line("");
1783 assert_eq!(c.sha, "");
1784 assert_eq!(c.short_sha, "");
1785 assert_eq!(c.author, "");
1786 assert_eq!(c.subject, "");
1787 }
1788
1789 #[test]
1790 fn parse_commit_line_partial_fields() {
1791 let c = parse_commit_line("sha1|sha_short");
1792 assert_eq!(c.sha, "sha1");
1793 assert_eq!(c.short_sha, "sha_short");
1794 assert_eq!(c.author, "");
1795 }
1796
1797 #[test]
1798 fn parse_commit_line_subject_with_pipe() {
1799 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
1801 let c = parse_commit_line(line);
1802 assert_eq!(c.subject, "subject with | pipe inside");
1803 }
1804
1805 #[test]
1808 fn parse_git_date_valid_rfc3339() {
1809 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
1810 assert!(dt.is_some());
1811 }
1812
1813 #[test]
1814 fn parse_git_date_invalid_returns_none() {
1815 assert!(parse_git_date("not-a-date").is_none());
1816 assert!(parse_git_date("").is_none());
1817 }
1818
1819 #[test]
1820 fn parse_git_date_with_offset_converts_to_utc() {
1821 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
1822 assert_eq!(dt.time().hour(), 7);
1824 }
1825
1826 #[test]
1827 fn port_of_git_url_unknown_scheme_returns_none() {
1828 assert_eq!(port_of_git_url("https://host/repo"), Some(443));
1830 assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
1831 assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
1832 assert_eq!(port_of_git_url("file://host/repo"), None);
1834 assert_eq!(port_of_git_url("ftp://host/repo"), None);
1835 }
1836
1837 #[test]
1840 fn hostkey_normalizes_punctuation_and_case() {
1841 assert_eq!(
1842 hostkey("bitbucket.instance2.com"),
1843 "BITBUCKET_INSTANCE2_COM"
1844 );
1845 assert_eq!(hostkey("git-host.corp"), "GIT_HOST_CORP");
1846 assert_eq!(hostkey("host:7990"), "HOST_7990");
1847 }
1848
1849 #[test]
1850 fn resolve_credential_https_env_wins() {
1851 let _g = env_lock();
1852 let host = "cred-https-test.example";
1853 let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
1854 unsafe { std::env::set_var(&key, "alice:secrettoken") };
1856 let cred = resolve_credential(host, None);
1857 unsafe { std::env::remove_var(&key) };
1859 match cred {
1860 Some(GitCredential::Https { user, token }) => {
1861 assert_eq!(user, "alice");
1862 assert_eq!(token, "secrettoken");
1863 }
1864 _ => panic!("expected an HTTPS credential from the env registry"),
1865 }
1866 }
1867
1868 #[test]
1869 fn resolve_credential_ssh_key_env() {
1870 let _g = env_lock();
1871 let host = "cred-ssh-test.example";
1872 let key = format!("SLOC_GIT_SSHKEY_{}", hostkey(host));
1873 unsafe { std::env::set_var(&key, "/home/u/.ssh/id_ed25519") };
1875 let cred = resolve_credential(host, None);
1876 unsafe { std::env::remove_var(&key) };
1878 match cred {
1879 Some(GitCredential::Ssh { key_path }) => {
1880 assert_eq!(key_path, "/home/u/.ssh/id_ed25519");
1881 }
1882 _ => panic!("expected an SSH credential from the env registry"),
1883 }
1884 }
1885
1886 #[test]
1887 fn resolve_credential_none_falls_through() {
1888 assert!(resolve_credential("no-such-cred-host.invalid", None).is_none());
1890 }
1891
1892 #[test]
1893 fn cred_injection_https_keeps_secret_out_of_config() {
1894 let _g = env_lock();
1895 let host = "inj-test.example";
1896 let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
1897 unsafe { std::env::set_var(&key, "bob:tok123") };
1899 let inj = cred_injection(host, None);
1900 unsafe { std::env::remove_var(&key) };
1902
1903 assert_eq!(
1905 inj.config.first().map(String::as_str),
1906 Some("credential.helper=")
1907 );
1908 assert!(
1909 inj.config
1910 .iter()
1911 .any(|c| c.contains("$GIT_U") && c.contains("$GIT_P"))
1912 );
1913 assert!(
1914 !inj.config.iter().any(|c| c.contains("tok123")),
1915 "the token must NEVER appear in git config / argv"
1916 );
1917 assert!(inj.env.iter().any(|(k, v)| k == "GIT_U" && v == "bob"));
1918 assert!(inj.env.iter().any(|(k, v)| k == "GIT_P" && v == "tok123"));
1919 }
1920
1921 #[test]
1922 fn resolve_credential_port_qualified_key_wins() {
1923 let _g = env_lock();
1924 let host = "cred-port-test.example";
1925 let port_key = format!("SLOC_GIT_CRED_{}", hostkey(&format!("{host}:7990")));
1926 let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
1927 unsafe {
1929 std::env::set_var(&port_key, "svc-port:porttoken");
1930 std::env::set_var(&bare_key, "svc-bare:baretoken");
1931 }
1932 let with_port = resolve_credential(host, Some(7990));
1933 let without_port = resolve_credential(host, None);
1934 unsafe {
1936 std::env::remove_var(&port_key);
1937 std::env::remove_var(&bare_key);
1938 }
1939 match with_port {
1940 Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-port"),
1941 _ => panic!("port-qualified key should win when a port is present"),
1942 }
1943 match without_port {
1944 Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-bare"),
1945 _ => panic!("bare-host key should resolve when no port is given"),
1946 }
1947 }
1948
1949 #[test]
1950 fn resolve_credential_falls_back_to_bare_when_only_bare_key_set() {
1951 let _g = env_lock();
1952 let host = "cred-fallback-test.example";
1953 let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
1954 unsafe { std::env::set_var(&bare_key, "svc:tok") };
1956 let cred = resolve_credential(host, Some(7990));
1958 unsafe { std::env::remove_var(&bare_key) };
1960 assert!(matches!(cred, Some(GitCredential::Https { .. })));
1961 }
1962
1963 #[test]
1964 fn explicit_port_of_git_url_extracts_or_none() {
1965 assert_eq!(
1966 explicit_port_of_git_url("https://git.corp:7990/team/repo.git"),
1967 Some(7990)
1968 );
1969 assert_eq!(
1970 explicit_port_of_git_url("https://git.corp/team/repo.git"),
1971 None
1972 );
1973 assert_eq!(
1974 explicit_port_of_git_url("ssh://git@host:2222/repo.git"),
1975 Some(2222)
1976 );
1977 assert_eq!(
1979 explicit_port_of_git_url("git@github.com:owner/repo.git"),
1980 None
1981 );
1982 assert_eq!(
1983 explicit_port_of_git_url("https://[fe80::1]:443/repo"),
1984 Some(443)
1985 );
1986 assert_eq!(explicit_port_of_git_url("https://[fe80::1]/repo"), None);
1987 }
1988
1989 #[test]
1992 fn classify_source_recognizes_each_form() {
1993 assert!(matches!(
1994 classify_source("https://github.com/o/r.git"),
1995 GitSource::Remote
1996 ));
1997 assert!(matches!(
1998 classify_source("git@github.com:o/r.git"),
1999 GitSource::Remote
2000 ));
2001 assert!(matches!(
2002 classify_source("ssh://git@h/o/r.git"),
2003 GitSource::Remote
2004 ));
2005 assert!(matches!(
2006 classify_source("file:///srv/mirror/r"),
2007 GitSource::FileUrl
2008 ));
2009 assert!(matches!(
2010 classify_source("/srv/mirror/r.bundle"),
2011 GitSource::Bundle
2012 ));
2013 assert!(matches!(
2014 classify_source(r"C:\mirror\r"),
2015 GitSource::LocalPath
2016 ));
2017 }
2018
2019 #[test]
2020 fn normalize_git_url_passes_local_sources_through() {
2021 for u in [
2022 "file:///srv/mirror/r",
2023 r"C:\mirror\repo",
2024 r"\\srv\share\repo",
2025 "/srv/x.bundle",
2026 ] {
2027 assert_eq!(
2028 normalize_git_url(u),
2029 u,
2030 "local source must pass through unchanged: {u}"
2031 );
2032 }
2033 }
2034
2035 #[test]
2036 fn file_url_to_path_posix_windows_and_rejects_host() {
2037 assert_eq!(
2038 file_url_to_path("file:///home/u/repo").unwrap(),
2039 "/home/u/repo"
2040 );
2041 assert_eq!(
2042 file_url_to_path("file:///C:/mirror/repo").unwrap(),
2043 "C:/mirror/repo"
2044 );
2045 assert!(
2046 file_url_to_path("file://server/share/repo").is_err(),
2047 "a file:// URL with a host authority must be rejected"
2048 );
2049 }
2050
2051 #[test]
2054 fn validate_local_source_rejected_when_disabled() {
2055 let _g = env_lock();
2056 unsafe {
2058 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2059 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2060 }
2061 assert!(validate_local_source("/srv/x.bundle", &GitSource::Bundle).is_err());
2062 }
2063
2064 #[test]
2065 fn validate_local_source_requires_root_when_enabled() {
2066 let _g = env_lock();
2067 unsafe {
2069 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2070 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2071 }
2072 let err = validate_local_source("/srv/x.bundle", &GitSource::Bundle)
2073 .unwrap_err()
2074 .to_string();
2075 unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2077 assert!(
2078 err.contains("SLOC_GIT_LOCAL_ROOT"),
2079 "must fail closed without a configured root: {err}"
2080 );
2081 }
2082
2083 #[test]
2084 fn validate_local_source_rejects_outside_root() {
2085 let _g = env_lock();
2086 let root = tempfile::tempdir().unwrap();
2087 let outside = tempfile::tempdir().unwrap();
2088 unsafe {
2090 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2091 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2092 }
2093 let outside_path = outside.path().to_string_lossy().into_owned();
2094 let res = validate_local_source(&outside_path, &GitSource::LocalPath);
2095 unsafe {
2097 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2098 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2099 }
2100 assert!(
2101 res.is_err(),
2102 "a source outside SLOC_GIT_LOCAL_ROOT must be rejected"
2103 );
2104 }
2105
2106 #[test]
2107 fn validate_local_source_accepts_inside_root() {
2108 let _g = env_lock();
2109 let root = tempfile::tempdir().unwrap();
2110 let inside = root.path().join("sub");
2111 std::fs::create_dir_all(&inside).unwrap();
2112 unsafe {
2114 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2115 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2116 }
2117 let inside_path = inside.to_string_lossy().into_owned();
2118 let res = validate_local_source(&inside_path, &GitSource::LocalPath);
2119 unsafe {
2121 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2122 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2123 }
2124 assert!(
2125 res.is_ok(),
2126 "a source under the root must be accepted: {res:?}"
2127 );
2128 }
2129
2130 #[test]
2131 fn validate_local_source_rejects_unc_even_when_enabled() {
2132 let _g = env_lock();
2133 let root = tempfile::tempdir().unwrap();
2134 unsafe {
2136 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2137 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2138 }
2139 let res = validate_local_source(r"\\attacker\share\repo", &GitSource::LocalPath);
2140 unsafe {
2142 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2143 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2144 }
2145 assert!(
2146 res.is_err(),
2147 "UNC/SMB paths must be treated as remote and rejected"
2148 );
2149 }
2150
2151 #[test]
2152 fn clone_or_fetch_file_url_rejected_without_gate() {
2153 let _g = env_lock();
2155 unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2157 let dest = tempfile::tempdir().unwrap();
2158 assert!(clone_or_fetch("file:///etc/passwd", dest.path()).is_err());
2159 }
2160}
2161
2162#[cfg(test)]
2169mod git_integration {
2170 use super::*;
2171 use std::path::Path;
2172 use tempfile::tempdir;
2173
2174 fn git(dir: &Path, args: &[&str]) {
2177 let status = std::process::Command::new("git")
2178 .args(args)
2179 .current_dir(dir)
2180 .env("GIT_AUTHOR_NAME", "Test")
2181 .env("GIT_AUTHOR_EMAIL", "test@example.com")
2182 .env("GIT_COMMITTER_NAME", "Test")
2183 .env("GIT_COMMITTER_EMAIL", "test@example.com")
2184 .status()
2185 .expect("git must be on PATH");
2186 assert!(status.success(), "git {args:?} failed");
2187 }
2188
2189 fn make_repo(dir: &Path) {
2191 git(dir, &["init", "-b", "main"]);
2192 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
2193 git(dir, &["add", "hello.txt"]);
2194 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
2195 }
2196
2197 #[test]
2200 fn run_git_success_returns_stdout() {
2201 let dir = tempdir().unwrap();
2202 make_repo(dir.path());
2203 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
2205 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
2206 }
2207
2208 #[test]
2209 fn run_git_failure_returns_error() {
2210 let dir = tempdir().unwrap();
2211 make_repo(dir.path());
2212 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
2213 assert!(result.is_err(), "nonexistent ref must return an error");
2214 }
2215
2216 #[test]
2219 fn clone_or_fetch_clones_local_repo() {
2220 let src = tempdir().unwrap();
2221 make_repo(src.path());
2222
2223 let dest_root = tempdir().unwrap();
2224 let dest = dest_root.path().join("clone");
2225
2226 std::fs::create_dir_all(&dest).unwrap();
2235 let src_str = src.path().to_str().unwrap();
2236 let dest_str = dest.to_str().unwrap();
2237 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2238 assert!(dest.join(".git").exists(), "clone must create .git dir");
2239
2240 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
2242 git(src.path(), &["add", "second.txt"]);
2243 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2244
2245 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
2250 }
2251
2252 #[test]
2253 fn list_branches_excludes_origin_head_symref() {
2254 let src = tempdir().unwrap();
2258 let inner = src.path().join("inner");
2259 std::fs::create_dir_all(&inner).unwrap();
2260 make_repo(&inner);
2261 git(&inner, &["branch", "feature-x"]);
2262
2263 let dest_root = tempdir().unwrap();
2264 let dest = dest_root.path().join("clone");
2265 let src_str = inner.to_str().unwrap();
2266 let dest_str = dest.to_str().unwrap();
2267 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2268 let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
2270
2271 let branches = list_branches(&dest).unwrap();
2272 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
2273 assert!(
2274 !names.contains(&"origin"),
2275 "origin/HEAD symref must not appear as a branch: {names:?}"
2276 );
2277 assert!(
2278 names.contains(&"main"),
2279 "main branch must be listed: {names:?}"
2280 );
2281 assert!(
2282 names.contains(&"feature-x"),
2283 "real branches must still be listed: {names:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn clone_or_fetch_rejects_http_plain_url() {
2289 let dest = tempdir().unwrap();
2290 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
2291 assert!(
2292 result.is_err(),
2293 "http:// must be rejected by validate_clone_url"
2294 );
2295 }
2296
2297 #[test]
2298 fn clone_or_fetch_rejects_link_local_url() {
2299 let dest = tempdir().unwrap();
2300 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
2301 assert!(result.is_err());
2302 }
2303
2304 #[test]
2307 fn clone_or_fetch_imports_git_bundle_under_local_root() {
2308 let _g = env_lock();
2309 let root = tempdir().unwrap();
2311 let src = root.path().join("src");
2312 std::fs::create_dir_all(&src).unwrap();
2313 make_repo(&src);
2314 let bundle = root.path().join("repo.bundle");
2315 run_git(
2316 &src,
2317 &["bundle", "create", bundle.to_str().unwrap(), "--all"],
2318 )
2319 .unwrap();
2320
2321 unsafe {
2323 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2324 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2325 }
2326 let dest_root = tempdir().unwrap();
2327 let dest = dest_root.path().join("clone");
2328 let res = clone_or_fetch(bundle.to_str().unwrap(), &dest);
2329 let refs = res.as_ref().ok().and(list_refs(&dest).ok());
2330 unsafe {
2332 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2333 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2334 }
2335
2336 res.unwrap();
2337 assert!(
2338 dest.join(".git").exists(),
2339 "bundle import must produce a clone"
2340 );
2341 let names: Vec<String> = refs
2342 .expect("refs must be listable from the imported clone")
2343 .branches
2344 .into_iter()
2345 .map(|b| b.name)
2346 .collect();
2347 assert!(
2348 names.iter().any(|n| n == "main"),
2349 "bundle clone must expose the main branch: {names:?}"
2350 );
2351 }
2352
2353 #[test]
2354 fn clone_or_fetch_imports_local_path_under_root() {
2355 let _g = env_lock();
2356 let root = tempdir().unwrap();
2357 let src = root.path().join("mirror");
2358 std::fs::create_dir_all(&src).unwrap();
2359 make_repo(&src);
2360
2361 unsafe {
2363 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2364 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2365 }
2366 let dest_root = tempdir().unwrap();
2367 let dest = dest_root.path().join("clone");
2368 let res = clone_or_fetch(src.to_str().unwrap(), &dest);
2369 unsafe {
2371 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2372 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2373 }
2374
2375 res.unwrap();
2376 assert!(
2377 dest.join(".git").exists(),
2378 "local-path import must produce a clone"
2379 );
2380 }
2381
2382 #[test]
2385 fn get_sha_returns_full_commit_hash() {
2386 let dir = tempdir().unwrap();
2387 make_repo(dir.path());
2388 let sha = get_sha(dir.path(), "HEAD").unwrap();
2389 assert_eq!(sha.len(), 40);
2390 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
2391 }
2392
2393 #[test]
2394 fn get_sha_nonexistent_ref_errors() {
2395 let dir = tempdir().unwrap();
2396 make_repo(dir.path());
2397 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
2398 }
2399
2400 #[test]
2403 fn list_commits_returns_at_least_one_commit() {
2404 let dir = tempdir().unwrap();
2405 make_repo(dir.path());
2406 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
2407 assert!(
2408 !commits.is_empty(),
2409 "must return at least the initial commit"
2410 );
2411 let c = &commits[0];
2412 assert_eq!(c.sha.len(), 40);
2413 assert!(!c.short_sha.is_empty());
2414 assert_eq!(c.author, "Test");
2415 assert_eq!(c.subject, "initial");
2416 }
2417
2418 #[test]
2419 fn list_commits_respects_limit() {
2420 let dir = tempdir().unwrap();
2421 make_repo(dir.path());
2422 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
2424 git(dir.path(), &["add", "b.txt"]);
2425 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2426
2427 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
2428 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
2429
2430 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
2431 assert_eq!(two.len(), 2, "limit=10 must return both commits");
2432 }
2433
2434 #[test]
2437 fn list_refs_returns_main_branch() {
2438 let src = tempdir().unwrap();
2439 make_repo(src.path());
2440
2441 let dest_root = tempdir().unwrap();
2443 let dest = dest_root.path().join("clone");
2444 let src_str = src.path().to_str().unwrap();
2445 let dest_str = dest.to_str().unwrap();
2446 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2447
2448 let refs = list_refs(&dest).unwrap();
2449 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
2450 assert!(
2451 branch_names.contains(&"main"),
2452 "branches must include 'main', got: {branch_names:?}"
2453 );
2454 }
2455
2456 #[test]
2457 fn list_refs_returns_tag() {
2458 let src = tempdir().unwrap();
2459 make_repo(src.path());
2460 git(src.path(), &["tag", "v1.0.0"]);
2461
2462 let dest_root = tempdir().unwrap();
2463 let dest = dest_root.path().join("clone");
2464 let src_str = src.path().to_str().unwrap();
2465 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
2466 run_git(&dest, &["fetch", "--tags"]).unwrap();
2468
2469 let refs = list_refs(&dest).unwrap();
2470 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
2471 assert!(
2472 tag_names.contains(&"v1.0.0"),
2473 "tags must include 'v1.0.0', got: {tag_names:?}"
2474 );
2475 }
2476
2477 #[test]
2480 fn create_and_destroy_worktree() {
2481 let repo = tempdir().unwrap();
2482 make_repo(repo.path());
2483
2484 let sha = get_sha(repo.path(), "HEAD").unwrap();
2485
2486 let wt_root = tempdir().unwrap();
2487 let wt_path = wt_root.path().join("worktree");
2488
2489 create_worktree(repo.path(), &sha, &wt_path).unwrap();
2490 assert!(
2491 wt_path.exists(),
2492 "worktree directory must exist after creation"
2493 );
2494 assert!(
2495 wt_path.join("hello.txt").exists(),
2496 "worktree must contain committed files"
2497 );
2498
2499 destroy_worktree(repo.path(), &wt_path).unwrap();
2500 assert!(
2501 !wt_path.exists(),
2502 "worktree directory must be removed after destroy"
2503 );
2504 }
2505
2506 #[test]
2507 fn destroy_worktree_on_nonexistent_path_succeeds() {
2508 let repo = tempdir().unwrap();
2510 make_repo(repo.path());
2511 let nonexistent = repo.path().join("does_not_exist");
2512 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
2513 }
2514
2515 #[test]
2516 fn create_worktree_resolves_non_default_remote_branch() {
2517 let src = tempdir().unwrap();
2521 let inner = src.path().join("inner");
2522 std::fs::create_dir_all(&inner).unwrap();
2523 make_repo(&inner);
2524 git(&inner, &["checkout", "-b", "feature-x"]);
2525 std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
2526 git(&inner, &["add", "feat.txt"]);
2527 git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
2528 git(&inner, &["checkout", "main"]);
2529
2530 let dest_root = tempdir().unwrap();
2531 let dest = dest_root.path().join("clone");
2532 run_git(
2533 src.path(),
2534 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2535 )
2536 .unwrap();
2537
2538 let wt_root = tempdir().unwrap();
2540 let wt = wt_root.path().join("wt");
2541 create_worktree(&dest, "feature-x", &wt).unwrap();
2542 assert!(
2543 wt.join("feat.txt").exists(),
2544 "worktree must contain the feature branch's file"
2545 );
2546 destroy_worktree(&dest, &wt).unwrap();
2547 }
2548
2549 #[test]
2550 fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
2551 let src = tempdir().unwrap();
2552 let inner = src.path().join("inner");
2553 std::fs::create_dir_all(&inner).unwrap();
2554 make_repo(&inner);
2555 git(&inner, &["branch", "release-1"]);
2556
2557 let dest_root = tempdir().unwrap();
2558 let dest = dest_root.path().join("clone");
2559 run_git(
2560 src.path(),
2561 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2562 )
2563 .unwrap();
2564
2565 let sha = resolve_committish(&dest, "release-1").unwrap();
2567 assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
2568 assert!(resolve_committish(&dest, "no-such-branch").is_err());
2570 }
2571}