use std::path::Path;
use std::process::Command;
use sloc_git::ops::resolve_committish;
use sloc_git::{
clone_or_fetch, create_worktree, destroy_worktree, get_sha, hmac_sha256_hex, list_commits,
list_refs, parse_bitbucket_push, parse_gitlab_push,
};
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(["-c", "user.email=t@t", "-c", "user.name=Tester"])
.args(args)
.current_dir(dir)
.output()
.expect("git command should run");
assert!(
status.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&status.stderr)
);
}
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
run(p, &["init"]);
std::fs::write(p.join("a.txt"), "hello\n").unwrap();
run(p, &["add", "a.txt"]);
run(p, &["commit", "-m", "initial commit"]);
run(p, &["tag", "v1.0.0"]);
dir
}
#[test]
fn list_refs_and_commits_on_real_repo() {
if !git_available() {
eprintln!("git not available — skipping");
return;
}
let repo = init_repo();
let p = repo.path();
let refs = list_refs(p).expect("list_refs should succeed");
assert!(
refs.tags.iter().any(|t| t.name == "v1.0.0"),
"tag v1.0.0 should be listed: {:?}",
refs.tags
);
assert!(
!refs.recent_commits.is_empty(),
"at least one recent commit expected"
);
let commits = list_commits(p, "HEAD", 5).expect("list_commits should succeed");
assert_eq!(commits.len(), 1, "one commit in the fixture repo");
assert_eq!(commits[0].subject, "initial commit");
assert!(!commits[0].sha.is_empty(), "commit SHA must be populated");
}
#[test]
fn get_sha_and_resolve_committish_on_real_repo() {
if !git_available() {
eprintln!("git not available — skipping");
return;
}
let repo = init_repo();
let p = repo.path();
let head = get_sha(p, "HEAD").expect("HEAD should resolve");
assert_eq!(head.len(), 40, "full SHA should be 40 hex chars: {head}");
let tag_commit = resolve_committish(p, "v1.0.0").expect("tag should resolve");
assert_eq!(tag_commit, head, "the tag points at HEAD in this fixture");
let missing = resolve_committish(p, "no-such-ref");
assert!(missing.is_err(), "unknown ref must error");
}
#[test]
fn create_and_destroy_worktree_on_real_repo() {
if !git_available() {
eprintln!("git not available — skipping");
return;
}
let repo = init_repo();
let p = repo.path();
let wt_parent = tempfile::tempdir().unwrap();
let wt = wt_parent.path().join("checkout");
create_worktree(p, "HEAD", &wt).expect("worktree add should succeed");
assert!(
wt.join("a.txt").exists(),
"checked-out file should be present in the worktree"
);
destroy_worktree(p, &wt).expect("worktree remove should return Ok");
destroy_worktree(p, &wt).expect("second remove is a no-op that still returns Ok");
}
#[test]
fn clone_or_fetch_rejects_plaintext_http_scheme() {
let dir = tempfile::tempdir().unwrap();
let err = clone_or_fetch("http://example.com/repo.git", &dir.path().join("x")).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("URL rejected") || msg.contains("permitted"),
"http:// should be rejected: {msg}"
);
}
#[test]
fn clone_or_fetch_rejects_loopback_host() {
let dir = tempfile::tempdir().unwrap();
let err = clone_or_fetch("https://127.0.0.1/repo.git", &dir.path().join("x")).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("loopback") || msg.contains("rejected"),
"loopback host should be rejected: {msg}"
);
}
#[test]
fn clone_or_fetch_rejects_metadata_host() {
let dir = tempfile::tempdir().unwrap();
let err = clone_or_fetch(
"https://metadata.google.internal/repo.git",
&dir.path().join("x"),
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("rejected"), "metadata host rejected: {msg}");
}
#[test]
fn hmac_sha256_hex_is_deterministic_lowercase_hex() {
let a = hmac_sha256_hex(b"key", b"message");
let b = hmac_sha256_hex(b"key", b"message");
assert_eq!(a, b, "same input yields the same digest");
assert_eq!(a.len(), 64, "SHA-256 hex is 64 chars");
assert!(
a.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"digest must be lowercase hex: {a}"
);
assert_ne!(a, hmac_sha256_hex(b"other", b"message"));
}
#[test]
fn parse_gitlab_push_missing_repo_url_errors() {
let body = br#"{"ref":"refs/heads/main","checkout_sha":"deadbeef"}"#;
assert!(parse_gitlab_push(body).is_err());
}
#[test]
fn parse_bitbucket_push_missing_clone_url_errors() {
let body = br#"{
"actor": {"display_name": "x"},
"repository": {"links": {"clone": [{"name":"ssh","href":"git@bb:ws/r.git"}]}},
"push": {"changes": [{"new": {"name":"main","target":{"hash":"abc"}}}]}
}"#;
assert!(parse_bitbucket_push(body).is_err());
}
#[test]
fn parse_bitbucket_push_missing_branch_name_errors() {
let body = br#"{
"repository": {"links": {"clone": [{"name":"https","href":"https://bb/ws/r.git"}]}},
"push": {"changes": [{"new": {"target":{"hash":"abc"}}}]}
}"#;
assert!(parse_bitbucket_push(body).is_err());
}