use super::commit::{git_text, remove_settled, wrapped_commit};
use super::service_commit::{Running, query, start, through_service};
use super::support::{Fixture, executable, fixture, git, repository};
use git_policy_cli::service_journal::DONE_EXTENSION;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Output;
fn finished(fixture: &Fixture) -> usize {
return std::fs::read_dir(fixture.root.join("journal")).map_or(0, |entries| {
return entries
.flatten()
.filter(|entry| { return entry.path().extension().is_some_and(|extension| { return extension == DONE_EXTENSION; }); })
.count();
});
}
fn stored(fixture: &Fixture, repo: &Path) -> Vec<String> {
let formats: [&str; 6] = ["%T", "%B", "%an <%ae>", "%ad", "%cn <%ce>", "%P"];
let mut fields: Vec<String> =
formats.map(|format| { return git_text(fixture, repo, &["log", "-1", "--date=iso-strict", format!("--format={format}").as_str()]); }).to_vec();
fields.push(git_text(fixture, repo, &["cat-file", "commit", "HEAD"]).contains("\ngpgsig ").to_string());
return fields;
}
fn left_behind(fixture: &Fixture, repo: &Path) -> Vec<String> {
return vec![
git_text(fixture, repo, &["status", "--short"]),
git_text(fixture, repo, &["diff", "--cached", "--name-status"]),
git_text(fixture, repo, &["rev-list", "--count", "HEAD"]),
];
}
fn write(repo: &Path, name: &str, text: &str) {
std::fs::write(repo.join(name), text).expect("write");
}
fn repository_with_programs(fixture: &Fixture) -> (PathBuf, String) {
let repo: PathBuf = repository(fixture, OsStr::new("repo"));
write(&repo, "a.txt", "base\n");
write(&repo, "b.txt", "other\n");
git(fixture, &repo, &["add", "a.txt", "b.txt"]);
git(fixture, &repo, &["commit", "--quiet", "--message=root\n\nroot body"]);
git(fixture, &repo, &["config", "maintenance.autoDetach", "false"]);
let editor: PathBuf = fixture.root.join("typing-editor");
executable(editor.as_path(), b"#!/bin/sh\nsed -i '1s/^/edited: /' \"$1\"\n");
git(fixture, &repo, &["config", "core.editor", editor.to_str().expect("UTF-8")]);
let signer: PathBuf = fixture.root.join("stand-in-gpg");
executable(
signer.as_path(),
b"#!/bin/sh\ncat > /dev/null\necho '[GNUPG:] SIG_CREATED ' >&2\nprintf -- '-----BEGIN PGP SIGNATURE-----\\n\\nplaceholder\\n-----END PGP SIGNATURE-----\\n'\n",
);
git(fixture, &repo, &["config", "gpg.program", signer.to_str().expect("UTF-8")]);
let start_commit: String = query(fixture, &repo, &["rev-parse", "HEAD"]);
return (repo, start_commit);
}
fn edit_a(repo: &Path) {
write(repo, "a.txt", "base\nedited\n");
}
fn edit_a_and_stage(repo: &Path) {
edit_a(repo);
let output = std::process::Command::new("/usr/bin/git").current_dir(repo).args(["add", "a.txt"]).output().expect("add");
assert!(output.status.success());
}
fn edit_a_and_sign_by_configuration(repo: &Path) {
edit_a(repo);
let output = std::process::Command::new("/usr/bin/git").current_dir(repo).args(["config", "commit.gpgsign", "true"]).output().expect("config");
assert!(output.status.success());
}
fn edit_a_and_clean_up_by_configuration(repo: &Path) {
edit_a(repo);
let output = std::process::Command::new("/usr/bin/git").current_dir(repo).args(["config", "commit.cleanup", "verbatim"]).output().expect("config");
assert!(output.status.success());
}
struct Case {
name: &'static str,
command: &'static [&'static str],
prepare: fn(&Path),
date_kept: bool,
}
fn case(name: &'static str, command: &'static [&'static str], prepare: fn(&Path)) -> Case {
return Case { name, command, prepare, date_kept: false };
}
#[test]
fn commits_git_builds_for_the_service_equal_the_transaction_commit() {
let fixture: Fixture = fixture("service-native");
let (repo, start_commit) = repository_with_programs(&fixture);
let cases: Vec<Case> = vec![
case("editor, named path", &["commit", "--", "a.txt"], edit_a),
case("editor, seeded message", &["commit", "-e", "-m", "seed", "--", "a.txt"], edit_a),
case("editor, index", &["commit", "--no-enforce-only"], edit_a_and_stage),
case("editor, squash", &["commit", "--squash=HEAD", "--", "a.txt"], edit_a),
Case { date_kept: true, ..case("editor, amend", &["commit", "--amend", "--", "a.txt"], edit_a) },
case("signed by an option", &["commit", "-S", "-m", "signed", "--", "a.txt"], edit_a),
case("signed by configuration", &["commit", "-m", "signed", "--", "a.txt"], edit_a_and_sign_by_configuration),
Case { date_kept: true, ..case("signed amend", &["commit", "--amend", "--no-edit", "-S", "--", "a.txt"], edit_a) },
case("clean-up mode by configuration", &["commit", "-m", "kept \n\n\n", "--", "a.txt"], edit_a_and_clean_up_by_configuration),
case("scissors clean-up", &["commit", "--cleanup=scissors", "-m", "above\n# ------------------------ >8 ------------------------\nbelow", "--", "a.txt"], edit_a),
case("author found in history", &["commit", "--author=Fixture", "-m", "found", "--", "a.txt"], edit_a),
];
let name: String = format!("native-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
for entry in &cases {
let mut outcomes: Vec<(Vec<String>, Vec<String>, Option<i32>)> = Vec::new();
for through in [true, false] {
git(&fixture, &repo, &["reset", "--hard", "--quiet", start_commit.as_str()]);
let _unset = std::process::Command::new("/usr/bin/git").current_dir(&repo).args(["config", "--unset-all", "commit.gpgsign"]).output();
let _unset_cleanup = std::process::Command::new("/usr/bin/git").current_dir(&repo).args(["config", "--unset-all", "commit.cleanup"]).output();
git(&fixture, &repo, &["clean", "--force", "--quiet", "-d"]);
(entry.prepare)(&repo);
let before: usize = finished(&fixture);
let output: Output = if through {
through_service(&fixture, &repo, name.as_str(), entry.command)
} else {
wrapped_commit(&fixture, &repo, entry.command).output().expect("transaction")
};
if through {
assert_eq!(finished(&fixture), before + 1, "{}: the service landed it: {}", entry.name, String::from_utf8_lossy(&output.stderr));
}
outcomes.push((stored(&fixture, &repo), left_behind(&fixture, &repo), output.status.code()));
}
assert_eq!(outcomes[0].2, outcomes[1].2, "{}: exit status", entry.name);
assert_eq!(outcomes[0].2, Some(0), "{}", entry.name);
assert_eq!(outcomes[0].1, outcomes[1].1, "{}: what is left behind", entry.name);
for (index, (left, right)) in outcomes[0].0.iter().zip(&outcomes[1].0).enumerate() {
if index == 3 && !entry.date_kept {
continue;
}
assert_eq!(left, right, "{}: stored field {index}", entry.name);
}
}
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
fn verdict(fixture: &Fixture, repo: &Path) -> Vec<String> {
return ["%G?", "%GK", "%GS"].map(|format| { return git_text(fixture, repo, &["log", "-1", format!("--format={format}").as_str()]); }).to_vec();
}
#[test]
fn an_ssh_signed_commit_lands_through_the_service_and_verifies() {
let fixture: Fixture = fixture("service-native-ssh");
let (repo, start_commit) = repository_with_programs(&fixture);
let key: PathBuf = fixture.root.join("signing-key");
let made = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f"])
.arg(&key)
.output()
.expect("ssh-keygen starts");
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let public: String = std::fs::read_to_string(key.with_extension("pub")).expect("public key");
let allowed: PathBuf = fixture.root.join("allowed-signers");
std::fs::write(&allowed, format!("fixture@example.invalid {}", public.trim())).expect("allowed signers");
for (setting, value) in [
("gpg.format", "ssh"),
("user.signingkey", key.to_str().expect("UTF-8")),
("gpg.ssh.allowedsignersfile", allowed.to_str().expect("UTF-8")),
("commit.gpgsign", "true"),
] {
git(&fixture, &repo, &["config", setting, value]);
}
let name: String = format!("native-ssh-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
let mut outcomes: Vec<(Vec<String>, Vec<String>, Vec<String>)> = Vec::new();
for through in [true, false] {
git(&fixture, &repo, &["reset", "--hard", "--quiet", start_commit.as_str()]);
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = if through {
through_service(&fixture, &repo, name.as_str(), &["commit", "-m", "signed by ssh", "--", "a.txt"])
} else {
wrapped_commit(&fixture, &repo, &["commit", "-m", "signed by ssh", "--", "a.txt"]).output().expect("transaction")
};
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
if through {
assert_eq!(finished(&fixture), before + 1, "the service landed it");
}
git(&fixture, &repo, &["verify-commit", "HEAD"]);
assert!(
git_text(&fixture, &repo, &["cat-file", "commit", "HEAD"]).contains("\ngpgsig -----BEGIN SSH SIGNATURE-----"),
"the signature is Git's own SSH signature"
);
outcomes.push((stored(&fixture, &repo), left_behind(&fixture, &repo), verdict(&fixture, &repo)));
}
assert_eq!(outcomes[0].2, outcomes[1].2, "the verdict");
assert_eq!(outcomes[0].2[0].trim(), "G", "a good signature by a known signer");
assert_eq!(outcomes[0].1, outcomes[1].1, "what is left behind");
for (index, (left, right)) in outcomes[0].0.iter().zip(&outcomes[1].0).enumerate() {
if index != 3 {
assert_eq!(left, right, "stored field {index}");
}
}
assert_eq!(outcomes[0].0[6], "true", "signed");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
fn ssh_signing_through(fixture: &Fixture, repo: &Path, race: &str) -> PathBuf {
let key: PathBuf = fixture.root.join("signing-key");
let made = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f"])
.arg(&key)
.output()
.expect("ssh-keygen starts");
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let public: String = std::fs::read_to_string(key.with_extension("pub")).expect("public key");
let allowed: PathBuf = fixture.root.join("allowed-signers");
std::fs::write(&allowed, format!("fixture@example.invalid {}", public.trim())).expect("allowed signers");
let calls: PathBuf = fixture.root.join("signer-calls");
let signer: PathBuf = fixture.root.join("racing-signer");
let script: String = format!("#!/bin/sh\n{race}\necho call >> '{}'\nexec /usr/bin/ssh-keygen \"$@\"\n", calls.display());
executable(signer.as_path(), script.as_bytes());
for (setting, value) in [
("gpg.format", "ssh"),
("gpg.ssh.program", signer.to_str().expect("UTF-8")),
("user.signingkey", key.to_str().expect("UTF-8")),
("gpg.ssh.allowedsignersfile", allowed.to_str().expect("UTF-8")),
("commit.gpgsign", "true"),
] {
git(fixture, repo, &["config", setting, value]);
}
return calls;
}
fn racing_commit(repo: &Path, only_once_marker: Option<&Path>) -> String {
let guard: String = match only_once_marker {
Some(marker) => format!("[ -z \"$RACING\" ] && [ ! -e '{0}' ] && : > '{0}' && ", marker.display()),
None => String::from("[ -z \"$RACING\" ] && "),
};
return format!(
"{guard}RACING=1 env -u GIT_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_WORK_TREE -u GIT_COMMON_DIR \
GIT_AUTHOR_NAME=other GIT_AUTHOR_EMAIL=fixture@example.invalid GIT_COMMITTER_NAME=other GIT_COMMITTER_EMAIL=fixture@example.invalid \
/usr/bin/git -C '{}' commit --quiet --allow-empty --message=raced",
repo.display()
);
}
fn lines_in(path: &Path) -> usize {
return std::fs::read_to_string(path).map_or(0, |text| { return text.lines().count(); });
}
#[test]
fn a_signed_commit_is_built_again_on_the_new_tip_when_the_branch_moves() {
let fixture: Fixture = fixture("service-native-rebuild");
let (repo, start_commit) = repository_with_programs(&fixture);
let marker: PathBuf = fixture.root.join("raced-once");
let calls: PathBuf = ssh_signing_through(&fixture, &repo, racing_commit(&repo, Some(marker.as_path())).as_str());
let name: String = format!("native-rebuild-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "-m", "signed and moved", "--", "a.txt"]);
let stderr: String = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(output.status.success(), "{stderr}");
assert_eq!(finished(&fixture), before + 1, "the service landed it once");
assert_eq!(lines_in(&calls), 3, "the first build, the other commit's signature, and the build made again");
assert_eq!(query(&fixture, &repo, &["log", "-1", "--format=%s"]), "signed and moved");
assert_eq!(query(&fixture, &repo, &["log", "-1", "--format=%s", "HEAD^"]), "raced", "it sits on the other commit");
assert_eq!(query(&fixture, &repo, &["rev-parse", "HEAD~2"]), start_commit);
assert!(git_text(&fixture, &repo, &["cat-file", "commit", "HEAD"]).contains("\ngpgsig -----BEGIN SSH SIGNATURE-----"), "signed again");
assert!(String::from_utf8_lossy(&output.stdout).contains("signed and moved"), "one summary names the commit that landed: {}", String::from_utf8_lossy(&output.stdout));
assert_eq!(query(&fixture, &repo, &["show", "HEAD:a.txt"]), "base\nedited");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
#[test]
fn a_branch_that_keeps_moving_stops_the_rebuilds_at_the_bound() {
let fixture: Fixture = fixture("service-native-rebuild-bound");
let (repo, _start_commit) = repository_with_programs(&fixture);
let calls: PathBuf = ssh_signing_through(&fixture, &repo, racing_commit(&repo, None).as_str());
let name: String = format!("native-rebuild-bound-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "-m", "never lands", "--", "a.txt"]);
let stderr: String = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(output.status.code(), Some(1), "{stderr}");
let bound: usize = git_policy_cli::service_commit::REBUILD_ATTEMPTS;
assert!(stderr.contains(&format!("changed {bound} times")), "{stderr}");
assert_eq!(finished(&fixture), before, "nothing landed");
assert_eq!(lines_in(&calls), 2 * bound, "a build and the other commit's signature, for each attempt");
assert_eq!(query(&fixture, &repo, &["log", "--format=%s", "--grep=never lands"]), "");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
#[test]
fn a_signed_amend_is_not_built_again_when_the_branch_moves() {
let fixture: Fixture = fixture("service-native-rebuild-amend");
let (repo, _start_commit) = repository_with_programs(&fixture);
let calls: PathBuf = ssh_signing_through(&fixture, &repo, racing_commit(&repo, None).as_str());
let name: String = format!("native-rebuild-amend-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "--amend", "--no-edit", "--cleanup=scissors", "--", "a.txt"]);
let stderr: String = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("moved while this commit was being prepared"), "{stderr}");
assert_eq!(finished(&fixture), before);
assert_eq!(lines_in(&calls), 2, "one build, and the other commit's signature");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
#[test]
fn an_abandoned_editor_lands_nothing_and_leaves_no_scratch_behind() {
let fixture: Fixture = fixture("service-native-abort");
let (repo, start_commit) = repository_with_programs(&fixture);
let name: String = format!("native-abort-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
let empties: PathBuf = fixture.root.join("empty-editor");
executable(empties.as_path(), b"#!/bin/sh\n: > \"$1\"\n");
let failing: PathBuf = fixture.root.join("failing-editor");
executable(failing.as_path(), b"#!/bin/sh\nexit 3\n");
for (label, program) in [("an empty message", empties), ("an editor that fails", failing)] {
git(&fixture, &repo, &["config", "core.editor", program.to_str().expect("UTF-8")]);
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "--", "a.txt"]);
assert_eq!(output.status.code(), Some(1), "{label}: {}", String::from_utf8_lossy(&output.stderr));
assert!(!String::from_utf8_lossy(&output.stderr).contains("commit service"), "{label}: the service was never asked: {}", String::from_utf8_lossy(&output.stderr));
assert_eq!(finished(&fixture), before, "{label}: nothing landed");
assert_eq!(query(&fixture, &repo, &["rev-parse", "HEAD"]), start_commit, "{label}: the branch did not move");
let scratch: Vec<String> = std::fs::read_dir(repo.join(".git"))
.expect("git directory")
.flatten()
.map(|entry| { return entry.file_name().to_string_lossy().into_owned(); })
.filter(|entry| { return entry.starts_with("cli-git-service-"); })
.collect();
assert!(scratch.is_empty(), "{label}: left {scratch:?}");
}
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
#[test]
fn a_branch_that_moves_while_the_editor_is_open_refuses_the_commit_that_was_typed() {
let fixture: Fixture = fixture("service-native-moved");
let (repo, _start_commit) = repository_with_programs(&fixture);
let name: String = format!("native-moved-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
let racing: PathBuf = fixture.root.join("racing-editor");
let script: String = format!(
"#!/bin/sh\nsed -i '1s/^/typed: /' \"$1\"\nenv -u GIT_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_WORK_TREE -u GIT_COMMON_DIR \
GIT_AUTHOR_NAME=other GIT_AUTHOR_EMAIL=other@example.invalid GIT_COMMITTER_NAME=other GIT_COMMITTER_EMAIL=other@example.invalid \
/usr/bin/git -C '{}' commit --quiet --allow-empty --message='moved by someone'\n",
repo.display()
);
executable(racing.as_path(), script.as_bytes());
git(&fixture, &repo, &["config", "core.editor", racing.to_str().expect("UTF-8")]);
edit_a(&repo);
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "--", "a.txt"]);
let stderr: String = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("moved while this commit was being prepared"), "{stderr}");
assert_eq!(finished(&fixture), before, "nothing landed");
assert_eq!(query(&fixture, &repo, &["log", "-1", "--format=%s"]), "moved by someone");
assert_eq!(query(&fixture, &repo, &["log", "--format=%s", "--grep=typed"]), "", "the typed commit is not on the branch");
let saved: PathBuf = std::fs::read_dir(repo.join(".git"))
.expect("git directory")
.flatten()
.map(|entry| { return entry.path(); })
.find(|path| { return path.file_name().is_some_and(|file| { return file.to_string_lossy().starts_with("cli-git-saved-message-"); }); })
.expect("the typed message was saved");
assert!(stderr.contains(&format!("git commit -F {}", saved.display())), "{stderr}");
assert!(std::fs::read_to_string(&saved).expect("saved message").starts_with("typed:"), "the typed text is what was saved");
git(&fixture, &repo, &["config", "core.editor", "false"]);
let reused: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "-F", saved.to_str().expect("UTF-8"), "--", "a.txt"]);
assert!(reused.status.success(), "{}", String::from_utf8_lossy(&reused.stderr));
assert_eq!(query(&fixture, &repo, &["log", "-1", "--format=%s"]), "typed:");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}
#[test]
fn a_policy_that_rewrites_a_file_keeps_the_editor_commit_in_the_transaction() {
let fixture: Fixture = fixture("service-native-rewrite");
let (repo, _start_commit) = repository_with_programs(&fixture);
let name: String = format!("native-rewrite-{}", std::process::id());
let mut service: Running = start(&fixture, name.as_str());
write(&repo, "a.txt", "base\nno final newline");
let before: usize = finished(&fixture);
let output: Output = through_service(&fixture, &repo, name.as_str(), &["commit", "-e", "-m", "seed", "--", "a.txt"]);
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(finished(&fixture), before, "the service was not used");
assert_eq!(query(&fixture, &repo, &["log", "-1", "--format=%s"]), "edited: seed");
let _killed = service.kill();
let _waited = service.wait();
remove_settled(&fixture);
}