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