use std::path::Path;
use std::process::Command;
use super::*;
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?} in {dir:?}: {e}"));
assert!(
out.status.success(),
"git {args:?} in {dir:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn init_origin(path: &Path) -> &Path {
std::fs::create_dir_all(path).expect("mkdir origin");
git(path, &["init", "-q", "-b", "main"]);
git(path, &["config", "user.email", "test@example.com"]);
git(path, &["config", "user.name", "Test"]);
std::fs::write(path.join("file.txt"), "v1\n").expect("write file");
git(path, &["add", "."]);
git(path, &["commit", "-q", "-m", "initial"]);
path
}
fn clone_to(origin: &Path, dest: &Path) {
let out = Command::new("git")
.args([
"clone",
"-q",
origin.to_str().expect("utf8"),
dest.to_str().expect("utf8"),
])
.output()
.expect("spawn git clone");
assert!(
out.status.success(),
"git clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
git(dest, &["config", "user.email", "test@example.com"]);
git(dest, &["config", "user.name", "Test"]);
}
#[test]
fn equivalent_remote_spellings_match() {
let want = "github.com/bobmatnyc/trusty-tools";
for url in [
"git@github.com:bobmatnyc/trusty-tools.git",
"git@github.com:bobmatnyc/trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
"https://github.com/bobmatnyc/trusty-tools.git",
"https://github.com/bobmatnyc/trusty-tools/",
"ssh://git@github.com/bobmatnyc/trusty-tools.git",
"HTTPS://GitHub.com/BobMatNYC/Trusty-Tools.git",
] {
assert_eq!(canonical_remote(url), want, "canonicalizing {url}");
}
}
fn duetto_aliases() -> SshHostAliases {
SshHostAliases::parse("Host github-duetto\n HostName github.com\n")
}
fn alias_origin_checkout(tmp: &Path) -> std::path::PathBuf {
let base = init_origin(&tmp.join("base")).to_path_buf();
git(
&base,
&[
"remote",
"add",
"origin",
"git@github-duetto:duettoresearch/APEX.git",
],
);
base
}
#[test]
fn an_ssh_alias_origin_matches_its_github_remote() {
let aliases = duetto_aliases();
let want = canonical_remote("https://github.com/duettoresearch/apex.git");
for url in [
"git@github-duetto:duettoresearch/APEX.git",
"github-duetto:duettoresearch/apex",
"ssh://git@github-duetto/duettoresearch/APEX.git",
"git+ssh://git@github-duetto/duettoresearch/apex",
] {
assert_eq!(
canonical_remote_with(url, &aliases),
want,
"canonicalizing {url}"
);
}
}
#[test]
fn a_host_with_no_alias_entry_canonicalizes_as_before() {
let aliases = duetto_aliases();
for url in [
"git@github.com:acme/widget.git",
"git@gitlab.com:acme/widget.git",
"git@gh-other:acme/widget.git",
"ssh://git@gh-other:2222/acme/widget.git",
"https://github-duetto/acme/widget",
"/tmp/github-duetto:x/widget",
] {
assert_eq!(
canonical_remote_with(url, &aliases),
canonical_remote(url),
"canonicalizing {url}"
);
}
}
#[test]
fn an_ssh_alias_origin_checkout_matches_its_github_remote() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let base = alias_origin_checkout(tmp.path());
verify_remote_matches(
&base,
"https://github.com/duettoresearch/apex.git",
&duetto_aliases(),
)
.expect("an alias origin is the repository its real host serves");
}
#[test]
fn an_unreadable_ssh_config_never_turns_an_alias_into_a_match() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let base = alias_origin_checkout(tmp.path());
let garbage = tmp.path().join("garbage-config");
std::fs::write(&garbage, "\u{0}\u{1} === not ssh config\nHostName\n").expect("write");
for config in [
tmp.path().join("no-such-config"),
tmp.path().to_path_buf(),
garbage,
] {
let aliases = SshHostAliases::load(&config);
let err = verify_remote_matches(
&base,
"https://github.com/duettoresearch/apex.git",
&aliases,
)
.expect_err("an unread alias must not match");
assert!(
matches!(err, ColdStartError::RemoteMismatch { .. }),
"config {config:?}: expected RemoteMismatch, got {err:?}"
);
}
}
#[test]
fn different_hosts_do_not_match() {
assert_ne!(
canonical_remote("git@github.com:acme/widget.git"),
canonical_remote("git@gitlab.com:acme/widget.git")
);
assert_ne!(
canonical_remote("https://github.com/acme/widget"),
canonical_remote("https://github.com/other/widget")
);
}
#[test]
fn a_remote_mismatch_never_carries_the_requested_token() {
const TOKEN: &str = "ghp_9124SyntheticNotARealToken0000";
let tmp = tempfile::TempDir::new().expect("temp dir");
let origin = init_origin(&tmp.path().join("origin")).to_path_buf();
let base = tmp.path().join("base");
clone_to(&origin, &base);
let requested = format!("https://x-access-token:{TOKEN}@github.com/someone/else.git");
let err = ensure_managed_checkout_at(&base, &requested, None)
.expect_err("a different remote must be refused");
let shown = err.to_string();
let debug = format!("{err:?}");
assert!(
!shown.contains(TOKEN),
"the error message carries the token"
);
assert!(
!debug.contains(TOKEN),
"the error's Debug carries the token"
);
assert!(
shown.contains("https://***@github.com/someone/else.git"),
"the message no longer names the requested repository"
);
}
#[test]
fn existing_checkout_on_a_different_remote_fails_loud() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let origin = init_origin(&tmp.path().join("origin")).to_path_buf();
let base = tmp.path().join("base");
clone_to(&origin, &base);
let err = ensure_managed_checkout_at(&base, "https://github.com/someone/else.git", None)
.expect_err("a different remote must be refused");
assert!(
matches!(err, ColdStartError::RemoteMismatch { .. }),
"expected RemoteMismatch, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains(&base.display().to_string()),
"names path: {msg}"
);
assert!(msg.contains("someone/else"), "names requested repo: {msg}");
assert!(
msg.contains("will not re-point"),
"states the refusal: {msg}"
);
}
#[test]
fn existing_checkout_without_an_origin_fails_loud() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let base = init_origin(&tmp.path().join("base")).to_path_buf();
let err = ensure_managed_checkout_at(&base, "https://github.com/acme/widget.git", None)
.expect_err("a checkout with no origin must be refused");
assert!(
matches!(err, ColdStartError::NoOrigin { .. }),
"expected NoOrigin, got {err:?}"
);
}
#[test]
fn existing_checkout_with_an_unreadable_remote_fails_loud() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let base = init_origin(&tmp.path().join("base")).to_path_buf();
std::fs::write(base.join(".git/config"), "[remote \"origin\"\n").expect("corrupt config");
let err = ensure_managed_checkout_at(&base, "https://github.com/acme/widget.git", None)
.expect_err("an unreadable remote must be refused");
assert!(
matches!(err, ColdStartError::OriginUnreadable { .. }),
"expected OriginUnreadable, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("could not be read"),
"states the real reason: {msg}"
);
}
#[test]
fn dirty_existing_checkout_warns_and_proceeds() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let origin = init_origin(&tmp.path().join("origin")).to_path_buf();
let base = tmp.path().join("base");
clone_to(&origin, &base);
let head_before = git(&base, &["rev-parse", "HEAD"]);
std::fs::write(origin.join("file.txt"), "v2 from origin\n").expect("write");
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "moved forward"]);
std::fs::write(base.join("file.txt"), "MY UNCOMMITTED EDIT\n").expect("write");
let url = origin.to_string_lossy().into_owned();
let checkout = ensure_managed_checkout_at(&base, &url, None)
.expect("a dirty checkout must NOT be refused");
assert!(checkout.reused);
let reason = checkout
.refresh_skipped
.as_deref()
.expect("the skip must be REPORTED to the caller, not merely logged");
assert!(
reason.contains("uncommitted changes"),
"reason must state the cause: {reason}"
);
assert!(
reason.contains("file.txt"),
"reason must name what is dirty: {reason}"
);
assert_eq!(
std::fs::read_to_string(base.join("file.txt")).expect("read"),
"MY UNCOMMITTED EDIT\n"
);
assert_eq!(
git(&base, &["rev-parse", "HEAD"]),
head_before,
"a dirty tree must not be fast-forwarded"
);
assert_ne!(
git(&base, &["rev-parse", "refs/remotes/origin/main"]),
head_before,
"origin/main must be fetched even when the fast-forward is skipped"
);
}
#[test]
fn clean_matching_checkout_is_reused_and_refreshed() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let origin = init_origin(&tmp.path().join("origin")).to_path_buf();
let base = tmp.path().join("base");
clone_to(&origin, &base);
std::fs::write(origin.join("file.txt"), "v2 from origin\n").expect("write");
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "moved forward"]);
let url = origin.to_string_lossy().into_owned();
let checkout = ensure_managed_checkout_at(&base, &url, None).expect("clean reuse succeeds");
assert!(checkout.reused, "an existing checkout must report reused");
assert_eq!(checkout.base_path, base);
assert_eq!(
checkout.refresh_skipped, None,
"a clean checkout must report no skipped refresh"
);
assert_eq!(
std::fs::read_to_string(base.join("file.txt")).expect("read"),
"v2 from origin\n",
"the reused checkout must have been fast-forwarded"
);
}
#[test]
fn absent_path_is_cloned_and_reports_not_reused() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let origin = init_origin(&tmp.path().join("origin")).to_path_buf();
let base = tmp.path().join("owner").join("repo");
let url = origin.to_string_lossy().into_owned();
let checkout = ensure_managed_checkout_at(&base, &url, None).expect("fresh clone succeeds");
assert!(!checkout.reused, "a fresh clone must not report reused");
assert!(base.join(".git").exists(), "clone produced a git checkout");
assert_eq!(
std::fs::read_to_string(base.join("file.txt")).expect("read"),
"v1\n"
);
}
#[test]
fn dirty_message_truncates_long_status() {
let entries: Vec<String> = (0..25).map(|i| format!(" M file{i}.txt")).collect();
let rendered = summarize_entries(&entries);
assert!(rendered.contains("file0.txt"));
assert!(rendered.contains("file9.txt"));
assert!(!rendered.contains("file10.txt"));
assert!(rendered.contains("… and 15 more"));
let short = summarize_entries(&[" M a.txt".to_string()]);
assert_eq!(short, " M a.txt");
}