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