use super::*;
use crate::auto_push_single_flight::PushGit;
use crate::test_support::{fixture, git, remove};
fn fixture_environment() -> Vec<(OsString, OsString)> {
return [
("PATH", "/usr/bin:/bin"),
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_CONFIG_GLOBAL", "/nonexistent-global-config"),
("GIT_AUTHOR_NAME", "Fixture"),
("GIT_AUTHOR_EMAIL", "fixture@example.invalid"),
("GIT_COMMITTER_NAME", "Fixture"),
("GIT_COMMITTER_EMAIL", "fixture@example.invalid"),
]
.iter()
.map(|(name, value)| {
return (OsString::from(name), OsString::from(value));
})
.collect();
}
fn git_for(root: &Path) -> PushGit {
return PushGit {
real_git: PathBuf::from("/usr/bin/git"),
cwd: root.to_path_buf(),
environment: fixture_environment(),
};
}
fn repository_with_origin(name: &str) -> (PathBuf, PathBuf, String) {
let root = fixture(name);
let origin = root.join("origin.git");
let repo = root.join("repo");
git(
&root,
&["init", "--quiet", "--bare", origin.to_str().expect("utf-8")],
);
std::fs::create_dir_all(&repo).expect("repo");
git(&repo, &["init", "--quiet", "--initial-branch=main"]);
git(
&repo,
&["commit", "--quiet", "--allow-empty", "--message=base"],
);
git(
&repo,
&["remote", "add", "origin", origin.to_str().expect("utf-8")],
);
git(
&repo,
&["push", "--quiet", "origin", "HEAD:refs/heads/main"],
);
let base = head_of(&repo);
return (repo, origin, base);
}
fn head_of(repo: &Path) -> String {
return String::from_utf8_lossy(&git(repo, &["rev-parse", "HEAD"]).stdout)
.trim()
.to_owned();
}
fn remote_value(origin: &Path, reference: &str) -> Option<String> {
let output = std::process::Command::new("/usr/bin/git")
.current_dir(origin)
.env_clear()
.envs(fixture_environment())
.args(["rev-parse", "--verify", "--quiet", reference])
.output()
.expect("rev-parse");
if !output.status.success() {
return None;
}
return Some(String::from_utf8_lossy(&output.stdout).trim().to_owned());
}
fn post(arguments: &[&str]) -> Vec<OsString> {
return arguments.iter().map(OsString::from).collect();
}
#[test]
fn update_records_have_four_fields_and_other_shapes_are_refused() {
let parsed = parse_update_records(
"refs/heads/main abc refs/heads/main def\n\nrefs/heads/x 111 refs/heads/x 000\n",
"origin",
"/remote",
)
.expect("parses");
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].remote_name, "origin");
assert_eq!(parsed[0].remote_location, "/remote");
assert_eq!(parsed[0].remote_ref, "refs/heads/main");
assert_eq!(parsed[0].advertised_remote_oid, "def");
assert!(parse_update_records("refs/heads/main abc refs/heads/main\n", "o", "l").is_err());
assert!(parse_update_records("a b c d e\n", "o", "l").is_err());
assert_eq!(
parse_update_records("", "o", "l").expect("empty"),
Vec::new()
);
}
#[test]
fn only_all_zero_names_are_absent() {
assert!(is_zero_oid("0000000000000000000000000000000000000000"));
assert!(!is_zero_oid("0000000000000000000000000000000000000001"));
assert!(!is_zero_oid(""));
}
#[test]
fn probe_arguments_keep_git_options_before_the_terminator() {
let arguments = probe_arguments(
&post(&["-C", "/repo"]),
&post(&["origin", "main", "--", "tail"]),
Path::new("/hooks"),
);
let text: Vec<String> = arguments
.iter()
.map(|argument| {
return argument.to_string_lossy().into_owned();
})
.collect();
assert_eq!(
text,
vec![
"-C",
"/repo",
"-c",
"core.hooksPath=/hooks",
"push",
"origin",
"main",
"--dry-run",
"--verify",
"--",
"tail"
]
);
}
#[test]
fn the_hook_writes_an_exclusive_capture_of_bounded_input() {
let source = hook_source();
assert!(source.starts_with("#!/bin/sh\n"), "{source}");
assert!(source.contains("set -C"), "{source}");
assert!(
source.contains("$CLI_GIT_PUSH_FACTS_DIRECTORY/$$.capture"),
"{source}"
);
assert!(
source.contains(&format!("head -c {}", MAX_HOOK_INPUT_BYTES + 1)),
"{source}"
);
}
#[test]
fn a_new_branch_is_one_update_with_no_remote_object() {
let (repo, origin, _base) = repository_with_origin("mp-probe-new");
git(&repo, &["checkout", "--quiet", "-b", "feature"]);
let landed = head_of(&repo);
git(
&repo,
&["commit", "--quiet", "--allow-empty", "--message=feature"],
);
let tip = head_of(&repo);
let updates = probe_manual_push_updates(&git_for(&repo), &[], &post(&["origin", "feature"]))
.expect("probes");
assert_eq!(
updates,
vec![PushUpdate {
local_oid: Some(tip.clone()),
remote_oid: None,
remote_name: "origin".to_owned(),
remote_ref: "refs/heads/feature".to_owned(),
}]
);
assert_eq!(
remote_value(&origin, "refs/heads/feature"),
None,
"the probe must not publish"
);
assert_ne!(landed, tip);
remove(repo.parent().expect("root"));
}
#[test]
fn a_fast_forward_names_the_prior_remote_value() {
let (repo, origin, base) = repository_with_origin("mp-probe-ff");
git(
&repo,
&["commit", "--quiet", "--allow-empty", "--message=next"],
);
let tip = head_of(&repo);
let updates = probe_manual_push_updates(&git_for(&repo), &[], &post(&["origin", "main"]))
.expect("probes");
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].local_oid.as_deref(), Some(tip.as_str()));
assert_eq!(updates[0].remote_oid.as_deref(), Some(base.as_str()));
assert_eq!(
remote_value(&origin, "refs/heads/main").as_deref(),
Some(base.as_str())
);
remove(repo.parent().expect("root"));
}
#[test]
fn a_deletion_has_no_local_object() {
let (repo, _origin, base) = repository_with_origin("mp-probe-delete");
let updates = probe_manual_push_updates(&git_for(&repo), &[], &post(&["origin", ":main"]))
.expect("probes");
assert_eq!(
updates,
vec![PushUpdate {
local_oid: None,
remote_oid: Some(base),
remote_name: "origin".to_owned(),
remote_ref: "refs/heads/main".to_owned(),
}]
);
remove(repo.parent().expect("root"));
}
#[test]
fn an_up_to_date_push_has_no_updates() {
let (repo, _origin, _base) = repository_with_origin("mp-probe-noop");
let updates = probe_manual_push_updates(&git_for(&repo), &[], &post(&["origin", "main"]))
.expect("probes");
assert_eq!(updates, Vec::new());
remove(repo.parent().expect("root"));
}
#[test]
fn an_unreachable_destination_fails_the_probe() {
let (repo, _origin, _base) = repository_with_origin("mp-probe-unreachable");
git(
&repo,
&["commit", "--quiet", "--allow-empty", "--message=next"],
);
let missing = repo.parent().expect("root").join("missing.git");
let result = probe_manual_push_updates(
&git_for(&repo),
&[],
&post(&[missing.to_str().expect("utf-8"), "main"]),
);
assert!(result.is_err());
remove(repo.parent().expect("root"));
}
#[test]
fn confirmation_refuses_a_remote_ref_that_changed_after_negotiation() {
let (repo, origin, base) = repository_with_origin("mp-confirm-changed");
let changed = ProbedUpdate {
local_ref: "refs/heads/main".to_owned(),
local_oid: head_of(&repo),
remote_name: "origin".to_owned(),
remote_location: origin.to_str().expect("utf-8").to_owned(),
remote_ref: "refs/heads/main".to_owned(),
advertised_remote_oid: "1".repeat(40),
};
assert!(confirm_group(&git_for(&repo), origin.to_str().expect("utf-8"), &[changed]).is_err());
let matching = ProbedUpdate {
local_ref: "refs/heads/main".to_owned(),
local_oid: head_of(&repo),
remote_name: "origin".to_owned(),
remote_location: origin.to_str().expect("utf-8").to_owned(),
remote_ref: "refs/heads/main".to_owned(),
advertised_remote_oid: base.clone(),
};
let confirmed = confirm_group(
&git_for(&repo),
origin.to_str().expect("utf-8"),
&[matching],
)
.expect("confirms");
assert_eq!(confirmed[0].remote_oid.as_deref(), Some(base.as_str()));
remove(repo.parent().expect("root"));
}
#[test]
fn confirmation_refuses_a_remote_ref_deleted_after_negotiation() {
let (repo, origin, base) = repository_with_origin("mp-confirm-deleted");
let deleted = ProbedUpdate {
local_ref: "refs/heads/main".to_owned(),
local_oid: head_of(&repo),
remote_name: "origin".to_owned(),
remote_location: origin.to_str().expect("utf-8").to_owned(),
remote_ref: "refs/heads/gone".to_owned(),
advertised_remote_oid: base,
};
assert!(confirm_group(&git_for(&repo), origin.to_str().expect("utf-8"), &[deleted]).is_err());
remove(repo.parent().expect("root"));
}
#[test]
fn the_scratch_guard_removes_the_probe_directory() {
let root = fixture("mp-scratch-guard");
let scratch = root.join("probe");
std::fs::create_dir_all(scratch.join("captures")).expect("scratch");
drop(ScratchGuard(scratch.clone()));
assert!(!scratch.exists());
remove(&root);
}