1use std::path::Path;
5
6use anyhow::{bail, Context, Result};
7
8use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
9
10fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
13 let mut cmd = std::process::Command::new("git");
14 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
17 cmd.args(["-c", "http.sslVerify=false"]);
18 }
19 let out = cmd
20 .args(args)
21 .current_dir(repo)
22 .output()
23 .context("failed to spawn git process")?;
24 if !out.status.success() {
25 let stderr = String::from_utf8_lossy(&out.stderr);
26 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
27 }
28 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
29}
30
31#[must_use]
40pub fn normalize_git_url(raw: &str) -> String {
41 let url = raw.trim();
42 if url.starts_with("git@") || url.starts_with("ssh://") {
43 return url.to_owned();
44 }
45 let scheme = if url.starts_with("https://") {
46 "https"
47 } else if url.starts_with("http://") {
48 "http"
49 } else {
50 return url.to_owned();
51 };
52 let authority_and_path = &url[scheme.len() + 3..];
53 let (host, path) = authority_and_path
54 .find('/')
55 .map_or((authority_and_path, "/"), |i| {
56 (&authority_and_path[..i], &authority_and_path[i..])
57 });
58 let path = path.trim_end_matches('/');
59
60 try_normalize_bitbucket_server(scheme, host, path)
61 .or_else(|| try_normalize_gitlab(scheme, host, path))
62 .or_else(|| try_normalize_github(scheme, host, path))
63 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
64 .unwrap_or_else(|| url.to_owned())
65}
66
67fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
71 let path_lower = path.to_lowercase();
72 let proj_pos = path_lower.find("/projects/")?;
73 let after = &path[proj_pos + "/projects/".len()..];
74 let parts: Vec<&str> = after.splitn(4, '/').collect();
75 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
76 return None;
77 }
78 let context = &path[..proj_pos];
79 let project = parts[0].to_lowercase();
80 let repo = parts[2].trim_end_matches(".git");
81 Some(format!(
82 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
83 ))
84}
85
86fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
89 let idx = path.find("/-/")?;
90 let repo_path = path[..idx].trim_end_matches(".git");
91 Some(format!("{scheme}://{host}{repo_path}.git"))
92}
93
94fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
97 if host != "github.com" && !host.ends_with(".github.com") {
98 return None;
99 }
100 let p = path.trim_start_matches('/');
101 let parts: Vec<&str> = p.splitn(4, '/').collect();
102 if parts.len() < 3
103 || !matches!(
104 parts[2],
105 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
106 )
107 {
108 return None;
109 }
110 let owner = parts[0];
111 let repo = parts[1].trim_end_matches(".git");
112 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
113}
114
115fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
118 if host != "bitbucket.org" {
119 return None;
120 }
121 let p = path.trim_start_matches('/');
122 let parts: Vec<&str> = p.splitn(4, '/').collect();
123 if parts.len() < 3 || parts[2] != "src" {
124 return None;
125 }
126 let ws = parts[0];
127 let repo = parts[1].trim_end_matches(".git");
128 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
129}
130
131fn validate_clone_url(url: &str) -> Result<()> {
134 let lower = url.to_lowercase();
135 let allowed = ["https://", "git://", "ssh://", "git@"];
137 if !allowed.iter().any(|p| lower.starts_with(p)) {
138 bail!(
139 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
140 permitted (got {url:?})"
141 );
142 }
143 let blocked = [
145 "169.254.",
146 "metadata.google.internal",
147 "100.100.100.",
148 "[fd",
149 "[fe80",
150 ];
151 if blocked.iter().any(|b| lower.contains(b)) {
152 bail!("git URL rejected: link-local and metadata service addresses are not permitted");
153 }
154 Ok(())
155}
156
157pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
166 let normalized = normalize_git_url(url);
167 let url = normalized.as_str();
168 validate_clone_url(url)?;
169 if dest.join(".git").exists() {
170 run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
171 } else {
172 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
173 let dest_str = dest.to_str().unwrap_or(".");
174 let parent = dest.parent().unwrap_or(dest);
175 run_git(
176 parent,
177 &["clone", "--no-single-branch", "--depth=50", url, dest_str],
178 )?;
179 }
180 Ok(())
181}
182
183pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
188 run_git(repo, &["rev-parse", ref_name])
189}
190
191pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
198 let wt = worktree_path.to_str().unwrap_or(".");
199 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
200 Ok(())
201}
202
203pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
208 let wt = worktree_path.to_str().unwrap_or(".");
209 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
210 Ok(())
211}
212
213pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
220 Ok(RepoRefs {
221 branches: list_branches(repo)?,
222 tags: list_tags(repo)?,
223 recent_commits: list_commits(repo, "HEAD", 40)?,
224 })
225}
226
227fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
228 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
229 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
233 let refs = out
234 .lines()
235 .filter(|l| !l.trim().is_empty())
236 .map(|l| parse_ref_line(l, GitRefKind::Branch))
237 .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
239 .map(|mut r| {
240 if let Some(slash) = r.name.find('/') {
242 r.name = r.name[slash + 1..].to_owned();
243 }
244 r
245 })
246 .collect::<Vec<_>>();
247 Ok(refs)
248}
249
250fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
251 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
252 let out = run_git(
253 repo,
254 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
255 )?;
256 Ok(out
257 .lines()
258 .filter(|l| !l.trim().is_empty())
259 .map(|l| parse_ref_line(l, GitRefKind::Tag))
260 .collect())
261}
262
263fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
264 let parts: Vec<&str> = line.splitn(4, '|').collect();
265 let name = parts.first().copied().unwrap_or("").to_owned();
266 let sha = parts.get(1).copied().unwrap_or("").to_owned();
267 let date = parts.get(2).copied().and_then(parse_git_date);
268 let message = parts.get(3).map(|s| (*s).to_owned());
269 GitRef {
270 kind,
271 name,
272 sha,
273 date,
274 message,
275 }
276}
277
278pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
285 let fmt = "%H|%h|%an|%aI|%s";
286 let n = format!("-{limit}");
287 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
288 Ok(out
289 .lines()
290 .filter(|l| !l.trim().is_empty())
291 .map(parse_commit_line)
292 .collect())
293}
294
295fn parse_commit_line(line: &str) -> GitCommit {
296 let p: Vec<&str> = line.splitn(5, '|').collect();
297 let sha = p.first().copied().unwrap_or("").to_owned();
298 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
299 let author = p.get(2).copied().unwrap_or("").to_owned();
300 let date = p
301 .get(3)
302 .copied()
303 .and_then(parse_git_date)
304 .unwrap_or_default();
305 let subject = p.get(4).copied().unwrap_or("").to_owned();
306 GitCommit {
307 sha,
308 short_sha,
309 author,
310 date,
311 subject,
312 }
313}
314
315fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
316 chrono::DateTime::parse_from_rfc3339(s)
317 .ok()
318 .map(|d| d.with_timezone(&chrono::Utc))
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::GitRefKind;
325 use chrono::Timelike as _;
326
327 #[test]
330 fn normalize_github_tree_url() {
331 assert_eq!(
332 normalize_git_url("https://github.com/owner/repo/tree/main"),
333 "https://github.com/owner/repo.git"
334 );
335 }
336
337 #[test]
338 fn normalize_github_blob_url() {
339 assert_eq!(
340 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
341 "https://github.com/owner/repo.git"
342 );
343 }
344
345 #[test]
346 fn normalize_github_commits_url() {
347 assert_eq!(
348 normalize_git_url("https://github.com/owner/repo/commits/main"),
349 "https://github.com/owner/repo.git"
350 );
351 }
352
353 #[test]
354 fn normalize_github_releases_url() {
355 assert_eq!(
356 normalize_git_url("https://github.com/owner/repo/releases"),
357 "https://github.com/owner/repo.git"
358 );
359 }
360
361 #[test]
362 fn normalize_github_tags_url() {
363 assert_eq!(
364 normalize_git_url("https://github.com/owner/repo/tags"),
365 "https://github.com/owner/repo.git"
366 );
367 }
368
369 #[test]
370 fn normalize_github_branches_url() {
371 assert_eq!(
372 normalize_git_url("https://github.com/owner/repo/branches"),
373 "https://github.com/owner/repo.git"
374 );
375 }
376
377 #[test]
378 fn normalize_github_plain_clone_url_unchanged() {
379 let url = "https://github.com/owner/repo.git";
380 assert_eq!(normalize_git_url(url), url);
381 }
382
383 #[test]
384 fn normalize_gitlab_tree_url() {
385 assert_eq!(
386 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
387 "https://gitlab.com/group/subgroup/repo.git"
388 );
389 }
390
391 #[test]
392 fn normalize_gitlab_blob_url() {
393 assert_eq!(
394 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
395 "https://gitlab.com/org/repo.git"
396 );
397 }
398
399 #[test]
400 fn normalize_gitlab_self_hosted() {
401 assert_eq!(
402 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
403 "https://gitlab.corp.com/team/project.git"
404 );
405 }
406
407 #[test]
408 fn normalize_bitbucket_server_browse_url() {
409 assert_eq!(
410 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
411 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
412 );
413 }
414
415 #[test]
416 fn normalize_bitbucket_server_with_context() {
417 assert_eq!(
418 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
419 "https://host.com/ctx/scm/proj/repo.git"
420 );
421 }
422
423 #[test]
424 fn normalize_bitbucket_cloud_src_url() {
425 assert_eq!(
426 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
427 "https://bitbucket.org/workspace/repo.git"
428 );
429 }
430
431 #[test]
432 fn normalize_ssh_url_unchanged() {
433 let url = "git@github.com:owner/repo.git";
434 assert_eq!(normalize_git_url(url), url);
435 }
436
437 #[test]
438 fn normalize_ssh_protocol_url_unchanged() {
439 let url = "ssh://git@github.com/owner/repo.git";
440 assert_eq!(normalize_git_url(url), url);
441 }
442
443 #[test]
444 fn normalize_trims_leading_trailing_whitespace() {
445 assert_eq!(
446 normalize_git_url(" https://github.com/owner/repo/tree/main "),
447 "https://github.com/owner/repo.git"
448 );
449 }
450
451 #[test]
452 fn normalize_http_url_without_match_returned_unchanged() {
453 let url = "http://internal.corp.com/repo.git";
454 assert_eq!(normalize_git_url(url), url);
455 }
456
457 #[test]
460 fn validate_https_url_ok() {
461 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
462 }
463
464 #[test]
465 fn validate_git_protocol_url_ok() {
466 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
467 }
468
469 #[test]
470 fn validate_ssh_protocol_url_ok() {
471 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
472 }
473
474 #[test]
475 fn validate_git_at_url_ok() {
476 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
477 }
478
479 #[test]
480 fn validate_http_plain_rejected() {
481 assert!(
482 validate_clone_url("http://github.com/owner/repo.git").is_err(),
483 "plain http:// must be rejected"
484 );
485 }
486
487 #[test]
488 fn validate_link_local_169_254_rejected() {
489 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
490 }
491
492 #[test]
493 fn validate_google_metadata_endpoint_rejected() {
494 assert!(
495 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
496 );
497 }
498
499 #[test]
500 fn validate_alibaba_metadata_rejected() {
501 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
502 }
503
504 #[test]
505 fn validate_ipv6_fd_prefix_rejected() {
506 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_err());
507 }
508
509 #[test]
510 fn validate_ipv6_fe80_link_local_rejected() {
511 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
512 }
513
514 #[test]
515 fn validate_file_protocol_rejected() {
516 assert!(validate_clone_url("file:///etc/passwd").is_err());
517 }
518
519 #[test]
520 fn validate_empty_string_rejected() {
521 assert!(validate_clone_url("").is_err());
522 }
523
524 #[test]
527 fn bitbucket_server_uppercase_project_lowercased() {
528 let r = try_normalize_bitbucket_server(
529 "https",
530 "bb.corp.com",
531 "/projects/PROJ/repos/myrepo/browse",
532 );
533 assert_eq!(
534 r,
535 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
536 );
537 }
538
539 #[test]
540 fn bitbucket_server_without_projects_returns_none() {
541 assert!(
542 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
543 );
544 }
545
546 #[test]
547 fn bitbucket_server_missing_repos_segment_returns_none() {
548 assert!(
549 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
550 .is_none()
551 );
552 }
553
554 #[test]
557 fn gitlab_dash_tree_normalized() {
558 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
559 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
560 }
561
562 #[test]
563 fn gitlab_no_dash_returns_none() {
564 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
565 }
566
567 #[test]
568 fn gitlab_strips_existing_dot_git_before_readding() {
569 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
570 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
571 }
572
573 #[test]
576 fn github_tree_normalized() {
577 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
578 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
579 }
580
581 #[test]
582 fn github_non_github_host_returns_none() {
583 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
584 }
585
586 #[test]
587 fn github_plain_two_segment_path_returns_none() {
588 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
589 }
590
591 #[test]
592 fn github_unknown_third_segment_returns_none() {
593 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
594 }
595
596 #[test]
599 fn bitbucket_cloud_src_normalized() {
600 let r = try_normalize_bitbucket_cloud(
601 "https",
602 "bitbucket.org",
603 "/workspace/repo/src/main/README.md",
604 );
605 assert_eq!(
606 r,
607 Some("https://bitbucket.org/workspace/repo.git".to_owned())
608 );
609 }
610
611 #[test]
612 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
613 assert!(
614 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
615 );
616 }
617
618 #[test]
619 fn bitbucket_cloud_without_src_segment_returns_none() {
620 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
621 }
622
623 #[test]
626 fn parse_ref_line_all_fields() {
627 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
628 let r = parse_ref_line(line, GitRefKind::Branch);
629 assert_eq!(r.name, "main");
630 assert_eq!(r.sha, "abc1234");
631 assert!(r.date.is_some());
632 assert_eq!(r.message.as_deref(), Some("Initial commit"));
633 assert!(matches!(r.kind, GitRefKind::Branch));
634 }
635
636 #[test]
637 fn parse_ref_line_tag_kind() {
638 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
639 let r = parse_ref_line(line, GitRefKind::Tag);
640 assert_eq!(r.name, "v1.0.0");
641 assert!(matches!(r.kind, GitRefKind::Tag));
642 }
643
644 #[test]
645 fn parse_ref_line_name_only() {
646 let r = parse_ref_line("main", GitRefKind::Branch);
647 assert_eq!(r.name, "main");
648 assert_eq!(r.sha, "");
649 assert!(r.date.is_none());
650 assert!(r.message.is_none());
651 }
652
653 #[test]
654 fn parse_ref_line_invalid_date_gives_none() {
655 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
656 assert!(r.date.is_none());
657 assert_eq!(r.message.as_deref(), Some("msg"));
658 }
659
660 #[test]
661 fn parse_ref_line_empty_string() {
662 let r = parse_ref_line("", GitRefKind::Branch);
663 assert_eq!(r.name, "");
664 }
665
666 #[test]
669 fn parse_commit_line_all_fields() {
670 let line =
671 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
672 let c = parse_commit_line(line);
673 assert_eq!(c.sha, "abc1234567890abcdef");
674 assert_eq!(c.short_sha, "abc1234");
675 assert_eq!(c.author, "Alice Smith");
676 assert_eq!(c.subject, "Fix critical bug");
677 }
678
679 #[test]
680 fn parse_commit_line_empty() {
681 let c = parse_commit_line("");
682 assert_eq!(c.sha, "");
683 assert_eq!(c.short_sha, "");
684 assert_eq!(c.author, "");
685 assert_eq!(c.subject, "");
686 }
687
688 #[test]
689 fn parse_commit_line_partial_fields() {
690 let c = parse_commit_line("sha1|sha_short");
691 assert_eq!(c.sha, "sha1");
692 assert_eq!(c.short_sha, "sha_short");
693 assert_eq!(c.author, "");
694 }
695
696 #[test]
697 fn parse_commit_line_subject_with_pipe() {
698 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
700 let c = parse_commit_line(line);
701 assert_eq!(c.subject, "subject with | pipe inside");
702 }
703
704 #[test]
707 fn parse_git_date_valid_rfc3339() {
708 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
709 assert!(dt.is_some());
710 }
711
712 #[test]
713 fn parse_git_date_invalid_returns_none() {
714 assert!(parse_git_date("not-a-date").is_none());
715 assert!(parse_git_date("").is_none());
716 }
717
718 #[test]
719 fn parse_git_date_with_offset_converts_to_utc() {
720 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
721 assert_eq!(dt.time().hour(), 7);
723 }
724}
725
726#[cfg(test)]
733mod git_integration {
734 use super::*;
735 use std::path::Path;
736 use tempfile::tempdir;
737
738 fn git(dir: &Path, args: &[&str]) {
741 let status = std::process::Command::new("git")
742 .args(args)
743 .current_dir(dir)
744 .env("GIT_AUTHOR_NAME", "Test")
745 .env("GIT_AUTHOR_EMAIL", "test@example.com")
746 .env("GIT_COMMITTER_NAME", "Test")
747 .env("GIT_COMMITTER_EMAIL", "test@example.com")
748 .status()
749 .expect("git must be on PATH");
750 assert!(status.success(), "git {args:?} failed");
751 }
752
753 fn make_repo(dir: &Path) {
755 git(dir, &["init", "-b", "main"]);
756 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
757 git(dir, &["add", "hello.txt"]);
758 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
759 }
760
761 #[test]
764 fn run_git_success_returns_stdout() {
765 let dir = tempdir().unwrap();
766 make_repo(dir.path());
767 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
769 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
770 }
771
772 #[test]
773 fn run_git_failure_returns_error() {
774 let dir = tempdir().unwrap();
775 make_repo(dir.path());
776 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
777 assert!(result.is_err(), "nonexistent ref must return an error");
778 }
779
780 #[test]
783 fn clone_or_fetch_clones_local_repo() {
784 let src = tempdir().unwrap();
785 make_repo(src.path());
786
787 let dest_root = tempdir().unwrap();
788 let dest = dest_root.path().join("clone");
789
790 std::fs::create_dir_all(&dest).unwrap();
799 let src_str = src.path().to_str().unwrap();
800 let dest_str = dest.to_str().unwrap();
801 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
802 assert!(dest.join(".git").exists(), "clone must create .git dir");
803
804 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
806 git(src.path(), &["add", "second.txt"]);
807 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
808
809 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
814 }
815
816 #[test]
817 fn clone_or_fetch_rejects_http_plain_url() {
818 let dest = tempdir().unwrap();
819 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
820 assert!(
821 result.is_err(),
822 "http:// must be rejected by validate_clone_url"
823 );
824 }
825
826 #[test]
827 fn clone_or_fetch_rejects_link_local_url() {
828 let dest = tempdir().unwrap();
829 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
830 assert!(result.is_err());
831 }
832
833 #[test]
836 fn get_sha_returns_full_commit_hash() {
837 let dir = tempdir().unwrap();
838 make_repo(dir.path());
839 let sha = get_sha(dir.path(), "HEAD").unwrap();
840 assert_eq!(sha.len(), 40);
841 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
842 }
843
844 #[test]
845 fn get_sha_nonexistent_ref_errors() {
846 let dir = tempdir().unwrap();
847 make_repo(dir.path());
848 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
849 }
850
851 #[test]
854 fn list_commits_returns_at_least_one_commit() {
855 let dir = tempdir().unwrap();
856 make_repo(dir.path());
857 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
858 assert!(
859 !commits.is_empty(),
860 "must return at least the initial commit"
861 );
862 let c = &commits[0];
863 assert_eq!(c.sha.len(), 40);
864 assert!(!c.short_sha.is_empty());
865 assert_eq!(c.author, "Test");
866 assert_eq!(c.subject, "initial");
867 }
868
869 #[test]
870 fn list_commits_respects_limit() {
871 let dir = tempdir().unwrap();
872 make_repo(dir.path());
873 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
875 git(dir.path(), &["add", "b.txt"]);
876 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
877
878 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
879 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
880
881 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
882 assert_eq!(two.len(), 2, "limit=10 must return both commits");
883 }
884
885 #[test]
888 fn list_refs_returns_main_branch() {
889 let src = tempdir().unwrap();
890 make_repo(src.path());
891
892 let dest_root = tempdir().unwrap();
894 let dest = dest_root.path().join("clone");
895 let src_str = src.path().to_str().unwrap();
896 let dest_str = dest.to_str().unwrap();
897 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
898
899 let refs = list_refs(&dest).unwrap();
900 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
901 assert!(
902 branch_names.contains(&"main"),
903 "branches must include 'main', got: {branch_names:?}"
904 );
905 }
906
907 #[test]
908 fn list_refs_returns_tag() {
909 let src = tempdir().unwrap();
910 make_repo(src.path());
911 git(src.path(), &["tag", "v1.0.0"]);
912
913 let dest_root = tempdir().unwrap();
914 let dest = dest_root.path().join("clone");
915 let src_str = src.path().to_str().unwrap();
916 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
917 run_git(&dest, &["fetch", "--tags"]).unwrap();
919
920 let refs = list_refs(&dest).unwrap();
921 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
922 assert!(
923 tag_names.contains(&"v1.0.0"),
924 "tags must include 'v1.0.0', got: {tag_names:?}"
925 );
926 }
927
928 #[test]
931 fn create_and_destroy_worktree() {
932 let repo = tempdir().unwrap();
933 make_repo(repo.path());
934
935 let sha = get_sha(repo.path(), "HEAD").unwrap();
936
937 let wt_root = tempdir().unwrap();
938 let wt_path = wt_root.path().join("worktree");
939
940 create_worktree(repo.path(), &sha, &wt_path).unwrap();
941 assert!(
942 wt_path.exists(),
943 "worktree directory must exist after creation"
944 );
945 assert!(
946 wt_path.join("hello.txt").exists(),
947 "worktree must contain committed files"
948 );
949
950 destroy_worktree(repo.path(), &wt_path).unwrap();
951 assert!(
952 !wt_path.exists(),
953 "worktree directory must be removed after destroy"
954 );
955 }
956
957 #[test]
958 fn destroy_worktree_on_nonexistent_path_succeeds() {
959 let repo = tempdir().unwrap();
961 make_repo(repo.path());
962 let nonexistent = repo.path().join("does_not_exist");
963 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
964 }
965}