#![cfg(feature = "git")]
use repolith_actions::git_clone::GitClone;
use repolith_core::action::Action;
use repolith_core::types::{ActionId, BuildError, Ctx};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use std::time::Duration;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
fn run_git(cwd: &Path, args: &[&str]) {
let output = StdCommand::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git binary must be on PATH");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn make_bare_repo() -> (TempDir, PathBuf, String) {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("origin.git");
let work = tmp.path().join("seed");
StdCommand::new("git")
.args(["init", "--bare", bare.to_str().unwrap()])
.output()
.expect("git init --bare");
run_git(tmp.path(), &["init", work.to_str().unwrap()]);
run_git(&work, &["config", "user.email", "test@example.com"]);
run_git(&work, &["config", "user.name", "Test"]);
run_git(&work, &["config", "commit.gpgsign", "false"]);
std::fs::write(work.join("README.md"), "hello\n").unwrap();
run_git(&work, &["add", "README.md"]);
run_git(&work, &["commit", "-m", "initial"]);
run_git(&work, &["branch", "-M", "main"]);
run_git(&work, &["push", bare.to_str().unwrap(), "main:main"]);
run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
let head_out = StdCommand::new("git")
.current_dir(&work)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let head_sha = String::from_utf8(head_out.stdout)
.unwrap()
.trim()
.to_string();
(tmp, bare, head_sha)
}
fn ctx_with_cancel(cancel: CancellationToken) -> Ctx {
Ctx {
cancel,
workdir: PathBuf::from("/tmp"),
env: HashMap::new(),
}
}
fn fresh_ctx() -> Ctx {
ctx_with_cancel(CancellationToken::new())
}
fn add_commit(work: &Path) -> String {
std::fs::write(work.join("more.txt"), "second\n").unwrap();
run_git(work, &["add", "more.txt"]);
run_git(work, &["commit", "-m", "second"]);
let head = StdCommand::new("git")
.current_dir(work)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(head.stdout).unwrap().trim().to_string()
}
#[tokio::test]
async fn input_hash_is_stable_and_url_dependent() {
let (_guard, bare, _) = make_bare_repo();
let url = format!("file://{}", bare.display());
let action = GitClone {
id: ActionId("git-a".into()),
repo_url: url.clone(),
path: PathBuf::from("/tmp/never-clone-me"),
deps: vec![],
};
let h1 = action.input_hash(&fresh_ctx()).await.unwrap();
let h2 = action.input_hash(&fresh_ctx()).await.unwrap();
assert_eq!(h1, h2, "input_hash must be deterministic");
let action_other_url = GitClone {
id: ActionId("git-a".into()),
repo_url: format!("{url}-fake"),
path: PathBuf::from("/tmp/never-clone-me"),
deps: vec![],
};
let other = action_other_url.input_hash(&fresh_ctx()).await;
assert!(matches!(other, Err(BuildError::UpstreamUnreachable(_))));
}
#[tokio::test]
async fn first_execute_clones() {
let (guard, bare, head_sha) = make_bare_repo();
let dest = guard.path().join("dest");
let url = format!("file://{}", bare.display());
let action = GitClone {
id: ActionId("clone-once".into()),
repo_url: url,
path: dest.clone(),
deps: vec![],
};
let out = action.execute(&fresh_ctx()).await.unwrap();
assert!(dest.join(".git").is_dir(), "expected .git after clone");
assert!(out.stdout.contains(&head_sha));
}
#[tokio::test]
async fn second_execute_updates() {
let (guard, bare, head_sha) = make_bare_repo();
let dest = guard.path().join("dest");
let url = format!("file://{}", bare.display());
let action = GitClone {
id: ActionId("clone-then-update".into()),
repo_url: url,
path: dest.clone(),
deps: vec![],
};
let first = action.execute(&fresh_ctx()).await.unwrap();
assert!(first.stdout.contains(&head_sha));
let updater = guard.path().join("updater");
run_git(
guard.path(),
&["clone", bare.to_str().unwrap(), updater.to_str().unwrap()],
);
run_git(&updater, &["config", "user.email", "test@example.com"]);
run_git(&updater, &["config", "user.name", "Test"]);
run_git(&updater, &["config", "commit.gpgsign", "false"]);
let new_head = add_commit(&updater);
run_git(&updater, &["push", "origin", "HEAD:main"]);
let second = action.execute(&fresh_ctx()).await.unwrap();
assert!(
second.stdout.contains(&new_head),
"expected output to contain new HEAD {new_head}, got {}",
second.stdout
);
assert_ne!(first.output_hash, second.output_hash);
}
#[tokio::test]
async fn cancellation_aborts() {
let (guard, bare, _) = make_bare_repo();
let dest = guard.path().join("dest");
let url = format!("file://{}", bare.display());
let cancel = CancellationToken::new();
let action = GitClone {
id: ActionId("cancel-me".into()),
repo_url: url,
path: dest,
deps: vec![],
};
cancel.cancel();
let result = action.execute(&ctx_with_cancel(cancel)).await;
assert!(
matches!(result, Err(BuildError::Cancelled)),
"expected Cancelled, got {result:?}"
);
}
#[tokio::test]
async fn unreachable_url_yields_upstream_unreachable() {
let action = GitClone {
id: ActionId("nope".into()),
repo_url: "file:///definitely/does/not/exist/repo.git".to_string(),
path: PathBuf::from("/tmp/never-clone-me-2"),
deps: vec![],
};
let ctx = fresh_ctx();
let result = tokio::time::timeout(Duration::from_secs(10), action.input_hash(&ctx))
.await
.expect("ls-remote must complete within 10s");
assert!(matches!(result, Err(BuildError::UpstreamUnreachable(_))));
}
#[tokio::test]
async fn empty_ls_remote_yields_upstream_unreachable() {
let tmp = tempfile::tempdir().expect("tempdir");
let bare = tmp.path().join("empty.git");
StdCommand::new("git")
.args(["init", "--bare", bare.to_str().unwrap()])
.output()
.expect("git init --bare");
let action = GitClone {
id: ActionId("empty-head".into()),
repo_url: format!("file://{}", bare.display()),
path: PathBuf::from("/tmp/never-clone-me-3"),
deps: vec![],
};
let result = action.input_hash(&fresh_ctx()).await;
match result {
Err(BuildError::UpstreamUnreachable(msg)) => {
assert!(
msg.contains("no HEAD"),
"expected 'no HEAD' in error, got: {msg}"
);
}
other => panic!("expected UpstreamUnreachable(no HEAD), got {other:?}"),
}
}