use std::path::Path;
use anyhow::{bail, Context, Result};
use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
let mut cmd = std::process::Command::new("git");
if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
cmd.args(["-c", "http.sslVerify=false"]);
}
let out = cmd
.args(args)
.current_dir(repo)
.output()
.context("failed to spawn git process")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
#[must_use]
pub fn normalize_git_url(raw: &str) -> String {
let url = raw.trim();
if url.starts_with("git@") || url.starts_with("ssh://") {
return url.to_owned();
}
let scheme = if url.starts_with("https://") {
"https"
} else if url.starts_with("http://") {
"http"
} else {
return url.to_owned();
};
let authority_and_path = &url[scheme.len() + 3..];
let (host, path) = authority_and_path
.find('/')
.map_or((authority_and_path, "/"), |i| {
(&authority_and_path[..i], &authority_and_path[i..])
});
let path = path.trim_end_matches('/');
try_normalize_bitbucket_server(scheme, host, path)
.or_else(|| try_normalize_gitlab(scheme, host, path))
.or_else(|| try_normalize_github(scheme, host, path))
.or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
.unwrap_or_else(|| url.to_owned())
}
fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
let path_lower = path.to_lowercase();
let proj_pos = path_lower.find("/projects/")?;
let after = &path[proj_pos + "/projects/".len()..];
let parts: Vec<&str> = after.splitn(4, '/').collect();
if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
return None;
}
let context = &path[..proj_pos];
let project = parts[0].to_lowercase();
let repo = parts[2].trim_end_matches(".git");
Some(format!(
"{scheme}://{host}{context}/scm/{project}/{repo}.git"
))
}
fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
let idx = path.find("/-/")?;
let repo_path = path[..idx].trim_end_matches(".git");
Some(format!("{scheme}://{host}{repo_path}.git"))
}
fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
if host != "github.com" && !host.ends_with(".github.com") {
return None;
}
let p = path.trim_start_matches('/');
let parts: Vec<&str> = p.splitn(4, '/').collect();
if parts.len() < 3
|| !matches!(
parts[2],
"tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
)
{
return None;
}
let owner = parts[0];
let repo = parts[1].trim_end_matches(".git");
Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
}
fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
if host != "bitbucket.org" {
return None;
}
let p = path.trim_start_matches('/');
let parts: Vec<&str> = p.splitn(4, '/').collect();
if parts.len() < 3 || parts[2] != "src" {
return None;
}
let ws = parts[0];
let repo = parts[1].trim_end_matches(".git");
Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
}
fn validate_clone_url(url: &str) -> Result<()> {
let lower = url.to_lowercase();
let allowed = ["https://", "git://", "ssh://", "git@"];
if !allowed.iter().any(|p| lower.starts_with(p)) {
bail!(
"git URL rejected: only https://, git://, ssh://, and git@ URLs are \
permitted (got {url:?})"
);
}
let blocked = [
"169.254.",
"metadata.google.internal",
"100.100.100.",
"[fd",
"[fe80",
];
if blocked.iter().any(|b| lower.contains(b)) {
bail!("git URL rejected: link-local and metadata service addresses are not permitted");
}
Ok(())
}
pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
let normalized = normalize_git_url(url);
let url = normalized.as_str();
validate_clone_url(url)?;
if dest.join(".git").exists() {
run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
} else {
std::fs::create_dir_all(dest).context("failed to create clone directory")?;
let dest_str = dest.to_str().unwrap_or(".");
let parent = dest.parent().unwrap_or(dest);
run_git(
parent,
&["clone", "--no-single-branch", "--depth=50", url, dest_str],
)?;
}
Ok(())
}
pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
run_git(repo, &["rev-parse", ref_name])
}
pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
let wt = worktree_path.to_str().unwrap_or(".");
run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
Ok(())
}
pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
let wt = worktree_path.to_str().unwrap_or(".");
let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
Ok(())
}
pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
Ok(RepoRefs {
branches: list_branches(repo)?,
tags: list_tags(repo)?,
recent_commits: list_commits(repo, "HEAD", 40)?,
})
}
fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
let refs = out
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| parse_ref_line(l, GitRefKind::Branch))
.filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
.map(|mut r| {
if let Some(slash) = r.name.find('/') {
r.name = r.name[slash + 1..].to_owned();
}
r
})
.collect::<Vec<_>>();
Ok(refs)
}
fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
let out = run_git(
repo,
&["tag", "--sort=-creatordate", &format!("--format={fmt}")],
)?;
Ok(out
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| parse_ref_line(l, GitRefKind::Tag))
.collect())
}
fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
let parts: Vec<&str> = line.splitn(4, '|').collect();
let name = parts.first().copied().unwrap_or("").to_owned();
let sha = parts.get(1).copied().unwrap_or("").to_owned();
let date = parts.get(2).copied().and_then(parse_git_date);
let message = parts.get(3).map(|s| (*s).to_owned());
GitRef {
kind,
name,
sha,
date,
message,
}
}
pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
let fmt = "%H|%h|%an|%aI|%s";
let n = format!("-{limit}");
let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
Ok(out
.lines()
.filter(|l| !l.trim().is_empty())
.map(parse_commit_line)
.collect())
}
fn parse_commit_line(line: &str) -> GitCommit {
let p: Vec<&str> = line.splitn(5, '|').collect();
let sha = p.first().copied().unwrap_or("").to_owned();
let short_sha = p.get(1).copied().unwrap_or("").to_owned();
let author = p.get(2).copied().unwrap_or("").to_owned();
let date = p
.get(3)
.copied()
.and_then(parse_git_date)
.unwrap_or_default();
let subject = p.get(4).copied().unwrap_or("").to_owned();
GitCommit {
sha,
short_sha,
author,
date,
subject,
}
}
fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|d| d.with_timezone(&chrono::Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GitRefKind;
use chrono::Timelike as _;
#[test]
fn normalize_github_tree_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/tree/main"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_blob_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_commits_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/commits/main"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_releases_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/releases"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_tags_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/tags"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_branches_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/branches"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_plain_clone_url_unchanged() {
let url = "https://github.com/owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_gitlab_tree_url() {
assert_eq!(
normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
"https://gitlab.com/group/subgroup/repo.git"
);
}
#[test]
fn normalize_gitlab_blob_url() {
assert_eq!(
normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
"https://gitlab.com/org/repo.git"
);
}
#[test]
fn normalize_gitlab_self_hosted() {
assert_eq!(
normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
"https://gitlab.corp.com/team/project.git"
);
}
#[test]
fn normalize_bitbucket_server_browse_url() {
assert_eq!(
normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
"https://bitbucket.corp.com/scm/myproj/myrepo.git"
);
}
#[test]
fn normalize_bitbucket_server_with_context() {
assert_eq!(
normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
"https://host.com/ctx/scm/proj/repo.git"
);
}
#[test]
fn normalize_bitbucket_cloud_src_url() {
assert_eq!(
normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
"https://bitbucket.org/workspace/repo.git"
);
}
#[test]
fn normalize_ssh_url_unchanged() {
let url = "git@github.com:owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_ssh_protocol_url_unchanged() {
let url = "ssh://git@github.com/owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_trims_leading_trailing_whitespace() {
assert_eq!(
normalize_git_url(" https://github.com/owner/repo/tree/main "),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_http_url_without_match_returned_unchanged() {
let url = "http://internal.corp.com/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn validate_https_url_ok() {
assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_git_protocol_url_ok() {
assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_ssh_protocol_url_ok() {
assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_git_at_url_ok() {
assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
}
#[test]
fn validate_http_plain_rejected() {
assert!(
validate_clone_url("http://github.com/owner/repo.git").is_err(),
"plain http:// must be rejected"
);
}
#[test]
fn validate_link_local_169_254_rejected() {
assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
}
#[test]
fn validate_google_metadata_endpoint_rejected() {
assert!(
validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
);
}
#[test]
fn validate_alibaba_metadata_rejected() {
assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
}
#[test]
fn validate_ipv6_fd_prefix_rejected() {
assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_err());
}
#[test]
fn validate_ipv6_fe80_link_local_rejected() {
assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
}
#[test]
fn validate_file_protocol_rejected() {
assert!(validate_clone_url("file:///etc/passwd").is_err());
}
#[test]
fn validate_empty_string_rejected() {
assert!(validate_clone_url("").is_err());
}
#[test]
fn bitbucket_server_uppercase_project_lowercased() {
let r = try_normalize_bitbucket_server(
"https",
"bb.corp.com",
"/projects/PROJ/repos/myrepo/browse",
);
assert_eq!(
r,
Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
);
}
#[test]
fn bitbucket_server_without_projects_returns_none() {
assert!(
try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
);
}
#[test]
fn bitbucket_server_missing_repos_segment_returns_none() {
assert!(
try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
.is_none()
);
}
#[test]
fn gitlab_dash_tree_normalized() {
let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
}
#[test]
fn gitlab_no_dash_returns_none() {
assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
}
#[test]
fn gitlab_strips_existing_dot_git_before_readding() {
let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
}
#[test]
fn github_tree_normalized() {
let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
}
#[test]
fn github_non_github_host_returns_none() {
assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
}
#[test]
fn github_plain_two_segment_path_returns_none() {
assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
}
#[test]
fn github_unknown_third_segment_returns_none() {
assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
}
#[test]
fn bitbucket_cloud_src_normalized() {
let r = try_normalize_bitbucket_cloud(
"https",
"bitbucket.org",
"/workspace/repo/src/main/README.md",
);
assert_eq!(
r,
Some("https://bitbucket.org/workspace/repo.git".to_owned())
);
}
#[test]
fn bitbucket_cloud_non_bitbucket_host_returns_none() {
assert!(
try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
);
}
#[test]
fn bitbucket_cloud_without_src_segment_returns_none() {
assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
}
#[test]
fn parse_ref_line_all_fields() {
let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
let r = parse_ref_line(line, GitRefKind::Branch);
assert_eq!(r.name, "main");
assert_eq!(r.sha, "abc1234");
assert!(r.date.is_some());
assert_eq!(r.message.as_deref(), Some("Initial commit"));
assert!(matches!(r.kind, GitRefKind::Branch));
}
#[test]
fn parse_ref_line_tag_kind() {
let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
let r = parse_ref_line(line, GitRefKind::Tag);
assert_eq!(r.name, "v1.0.0");
assert!(matches!(r.kind, GitRefKind::Tag));
}
#[test]
fn parse_ref_line_name_only() {
let r = parse_ref_line("main", GitRefKind::Branch);
assert_eq!(r.name, "main");
assert_eq!(r.sha, "");
assert!(r.date.is_none());
assert!(r.message.is_none());
}
#[test]
fn parse_ref_line_invalid_date_gives_none() {
let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
assert!(r.date.is_none());
assert_eq!(r.message.as_deref(), Some("msg"));
}
#[test]
fn parse_ref_line_empty_string() {
let r = parse_ref_line("", GitRefKind::Branch);
assert_eq!(r.name, "");
}
#[test]
fn parse_commit_line_all_fields() {
let line =
"abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
let c = parse_commit_line(line);
assert_eq!(c.sha, "abc1234567890abcdef");
assert_eq!(c.short_sha, "abc1234");
assert_eq!(c.author, "Alice Smith");
assert_eq!(c.subject, "Fix critical bug");
}
#[test]
fn parse_commit_line_empty() {
let c = parse_commit_line("");
assert_eq!(c.sha, "");
assert_eq!(c.short_sha, "");
assert_eq!(c.author, "");
assert_eq!(c.subject, "");
}
#[test]
fn parse_commit_line_partial_fields() {
let c = parse_commit_line("sha1|sha_short");
assert_eq!(c.sha, "sha1");
assert_eq!(c.short_sha, "sha_short");
assert_eq!(c.author, "");
}
#[test]
fn parse_commit_line_subject_with_pipe() {
let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
let c = parse_commit_line(line);
assert_eq!(c.subject, "subject with | pipe inside");
}
#[test]
fn parse_git_date_valid_rfc3339() {
let dt = parse_git_date("2024-01-15T10:30:00+00:00");
assert!(dt.is_some());
}
#[test]
fn parse_git_date_invalid_returns_none() {
assert!(parse_git_date("not-a-date").is_none());
assert!(parse_git_date("").is_none());
}
#[test]
fn parse_git_date_with_offset_converts_to_utc() {
let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
assert_eq!(dt.time().hour(), 7);
}
}
#[cfg(test)]
mod git_integration {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.expect("git must be on PATH");
assert!(status.success(), "git {args:?} failed");
}
fn make_repo(dir: &Path) {
git(dir, &["init", "-b", "main"]);
std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
git(dir, &["add", "hello.txt"]);
git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
}
#[test]
fn run_git_success_returns_stdout() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
}
#[test]
fn run_git_failure_returns_error() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
assert!(result.is_err(), "nonexistent ref must return an error");
}
#[test]
fn clone_or_fetch_clones_local_repo() {
let src = tempdir().unwrap();
make_repo(src.path());
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
std::fs::create_dir_all(&dest).unwrap();
let src_str = src.path().to_str().unwrap();
let dest_str = dest.to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
assert!(dest.join(".git").exists(), "clone must create .git dir");
std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
git(src.path(), &["add", "second.txt"]);
git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
}
#[test]
fn clone_or_fetch_rejects_http_plain_url() {
let dest = tempdir().unwrap();
let result = clone_or_fetch("http://example.com/repo.git", dest.path());
assert!(
result.is_err(),
"http:// must be rejected by validate_clone_url"
);
}
#[test]
fn clone_or_fetch_rejects_link_local_url() {
let dest = tempdir().unwrap();
let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
assert!(result.is_err());
}
#[test]
fn get_sha_returns_full_commit_hash() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let sha = get_sha(dir.path(), "HEAD").unwrap();
assert_eq!(sha.len(), 40);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn get_sha_nonexistent_ref_errors() {
let dir = tempdir().unwrap();
make_repo(dir.path());
assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
}
#[test]
fn list_commits_returns_at_least_one_commit() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
assert!(
!commits.is_empty(),
"must return at least the initial commit"
);
let c = &commits[0];
assert_eq!(c.sha.len(), 40);
assert!(!c.short_sha.is_empty());
assert_eq!(c.author, "Test");
assert_eq!(c.subject, "initial");
}
#[test]
fn list_commits_respects_limit() {
let dir = tempdir().unwrap();
make_repo(dir.path());
std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
git(dir.path(), &["add", "b.txt"]);
git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
let one = list_commits(dir.path(), "HEAD", 1).unwrap();
assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
let two = list_commits(dir.path(), "HEAD", 10).unwrap();
assert_eq!(two.len(), 2, "limit=10 must return both commits");
}
#[test]
fn list_refs_returns_main_branch() {
let src = tempdir().unwrap();
make_repo(src.path());
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let src_str = src.path().to_str().unwrap();
let dest_str = dest.to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
let refs = list_refs(&dest).unwrap();
let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
assert!(
branch_names.contains(&"main"),
"branches must include 'main', got: {branch_names:?}"
);
}
#[test]
fn list_refs_returns_tag() {
let src = tempdir().unwrap();
make_repo(src.path());
git(src.path(), &["tag", "v1.0.0"]);
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let src_str = src.path().to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
run_git(&dest, &["fetch", "--tags"]).unwrap();
let refs = list_refs(&dest).unwrap();
let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
assert!(
tag_names.contains(&"v1.0.0"),
"tags must include 'v1.0.0', got: {tag_names:?}"
);
}
#[test]
fn create_and_destroy_worktree() {
let repo = tempdir().unwrap();
make_repo(repo.path());
let sha = get_sha(repo.path(), "HEAD").unwrap();
let wt_root = tempdir().unwrap();
let wt_path = wt_root.path().join("worktree");
create_worktree(repo.path(), &sha, &wt_path).unwrap();
assert!(
wt_path.exists(),
"worktree directory must exist after creation"
);
assert!(
wt_path.join("hello.txt").exists(),
"worktree must contain committed files"
);
destroy_worktree(repo.path(), &wt_path).unwrap();
assert!(
!wt_path.exists(),
"worktree directory must be removed after destroy"
);
}
#[test]
fn destroy_worktree_on_nonexistent_path_succeeds() {
let repo = tempdir().unwrap();
make_repo(repo.path());
let nonexistent = repo.path().join("does_not_exist");
assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
}
}