use super::commit::{remove_settled, wrapped_commit};
use super::support::{Fixture, Observed, fixture, git, repository, run_direct, run_wrapped, start_fixture_service, FixtureService};
use git_policy_cli::iso_time::now_iso;
use git_policy_cli::push_failure_notice::{ORPHAN_NOTICE_DIRECTORY, notice_directory};
use git_policy_cli::service_client::{Activation, ClientConfig, wake};
use git_policy_cli::service_instance::socket_for;
use git_policy_cli::service_server::Bind;
use git_policy_cli::service_work::work_directory;
use git_policy_cli::work_item::{WorkItem, WorkKind, enqueue};
use git_policy_cli::worktree_removal_rescue::SNAPSHOT_DIRECTORY;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
const REFUSAL: &str = "refusing this push by test policy";
const PATIENCE: Duration = Duration::from_secs(60);
struct Setup {
fixture: Fixture,
repo: PathBuf,
remote: PathBuf,
service: FixtureService,
}
fn setup(name: &str) -> Setup {
return setup_with(name, &[]);
}
fn setup_with(name: &str, service_environment: &[(&str, &str)]) -> Setup {
let fixture: Fixture = fixture(name);
let repo: PathBuf = repository(&fixture, OsStr::new("repo"));
let remote: PathBuf = fixture.root.join("remote.git");
git(&fixture, fixture.root.as_path(), &["init", "--quiet", "--bare", "--initial-branch=main", remote.to_str().expect("UTF-8")]);
std::fs::write(repo.join("a.txt"), "base\n").expect("a");
git(&fixture, &repo, &["add", "a.txt"]);
git(&fixture, &repo, &["commit", "--quiet", "--message=base"]);
git(&fixture, &repo, &["config", "maintenance.autoDetach", "false"]);
git(&fixture, &repo, &["remote", "add", "origin", remote.to_str().expect("UTF-8")]);
git(&fixture, &repo, &["push", "--quiet", "--set-upstream", "origin", "main"]);
for (variable, value) in service_environment {
fixture.service_environment.lock().expect("the service environment").push((std::ffi::OsString::from(variable), std::ffi::OsString::from(value)));
}
let service: FixtureService = start_fixture_service(&fixture);
service.serve(&fixture, repo.join(".git").as_path());
return Setup { fixture, repo, remote, service };
}
fn refuse_pushes(remote: &Path) {
let hook: PathBuf = remote.join("hooks/pre-receive");
std::fs::create_dir_all(hook.parent().expect("hooks directory")).expect("hooks");
std::fs::write(&hook, format!("#!/bin/sh\necho '{REFUSAL}' >&2\nexit 1\n")).expect("hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("executable");
}
fn accept_pushes(remote: &Path) {
std::fs::remove_file(remote.join("hooks/pre-receive")).expect("hook removed");
}
fn commit_through_service(setup: &Setup, content: &str) -> String {
return commit_through_service_in(setup, setup.repo.as_path(), content);
}
fn commit_through_service_in(setup: &Setup, directory: &Path, content: &str) -> String {
std::fs::write(directory.join("a.txt"), content).expect("edit");
let mut command: Command = wrapped_commit(&setup.fixture, directory, &["commit", "--quiet", "-m", content.trim(), "--", "a.txt"]);
command.env("XDG_RUNTIME_DIR", &setup.service.runtime);
let output: Output = command.output().expect("the wrapped commit runs");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let named = git(&setup.fixture, directory, &["rev-parse", "HEAD"]);
return String::from_utf8_lossy(&named.stdout).trim().to_string();
}
fn wait_until(what: &str, condition: &dyn Fn() -> bool) {
let began: Instant = Instant::now();
while !condition() {
assert!(began.elapsed() < PATIENCE, "never happened: {what}");
std::thread::sleep(Duration::from_millis(10));
}
}
fn common_dir(setup: &Setup) -> PathBuf {
return setup.repo.join(".git");
}
fn waiting_records(setup: &Setup) -> usize {
return waiting_records_in(common_dir(setup).as_path());
}
fn waiting_records_in(git_dir: &Path) -> usize {
return std::fs::read_dir(notice_directory(git_dir)).map_or(0, |listing| { return listing.flatten().count(); });
}
fn wait_until_settled(setup: &Setup, commit: &str) {
wait_until_settled_in(common_dir(setup).as_path(), commit);
}
fn wait_until_settled_in(git_dir: &Path, commit: &str) {
let work: PathBuf = git_dir.join("cli-git/work");
wait_until("the service settled the push", &|| {
return ["done", "failed"].iter().any(|state| { return work.join(state).join(format!("push-{commit}")).exists(); });
});
}
fn stderr_text(observed: &Observed) -> String {
return String::from_utf8_lossy(observed.stderr.as_slice()).into_owned();
}
#[test]
fn a_rejected_service_push_is_shown_on_the_next_command() {
let setup: Setup = setup("push-failure-shown");
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, commit.as_str());
let shown: Observed = run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]);
let text: String = stderr_text(&shown);
assert_eq!(shown.code, Some(0), "{text}");
let short: String = String::from_utf8_lossy(&git(&setup.fixture, &setup.repo, &["rev-parse", "--short", "HEAD"]).stdout).trim().to_string();
for expected in ["branch main", short.as_str(), REFUSAL, "saved in this repository", "`git push`"] {
assert!(text.contains(expected), "missing {expected:?} in: {text}");
}
remove_settled(&setup.fixture);
}
#[test]
fn a_shown_failure_is_not_shown_again() {
let setup: Setup = setup("push-failure-once");
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, commit.as_str());
let first: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(first.contains(REFUSAL), "{first}");
let second: Observed = run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]);
assert_eq!(stderr_text(&second), "", "the same failure again");
let third: Observed = run_wrapped(&setup.fixture, &setup.repo, &["log", "--oneline"]);
assert_eq!(stderr_text(&third), "", "the same failure again, in another command");
assert_eq!(waiting_records(&setup), 0);
remove_settled(&setup.fixture);
}
fn push_through_service(setup: &Setup, commit: &str) {
let item: WorkItem = WorkItem {
kind: WorkKind::Push,
repository: setup.repo.display().to_string(),
git_dir: common_dir(setup).display().to_string(),
branch_ref: String::from("refs/heads/main"),
oid: commit.to_string(),
parent: String::new(),
created: now_iso(),
tries: 0,
};
enqueue(work_directory(common_dir(setup).as_path()).as_path(), &item).expect("the push is recorded for the service");
let config: ClientConfig = ClientConfig {
address: Bind::Path(socket_for(setup.service.runtime.as_path(), setup.repo.join(".git").as_path())),
activation: Activation::Managed,
start_wait: PATIENCE,
commit_wait: PATIENCE,
};
wake(&config, common_dir(setup).as_path(), None, &mut std::io::sink()).expect("the service takes the wake");
wait_until_settled(setup, commit);
}
#[test]
fn a_later_successful_push_clears_the_unshown_failure() {
let setup: Setup = setup("push-failure-cleared");
refuse_pushes(setup.remote.as_path());
let failed: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, failed.as_str());
assert_eq!(waiting_records(&setup), 1, "the refusal was recorded");
accept_pushes(setup.remote.as_path());
std::fs::write(setup.repo.join("a.txt"), "third\n").expect("edit");
git(&setup.fixture, &setup.repo, &["commit", "--quiet", "--all", "--message=third"]);
let named = git(&setup.fixture, &setup.repo, &["rev-parse", "HEAD"]);
let pushed: String = String::from_utf8_lossy(&named.stdout).trim().to_string();
push_through_service(&setup, pushed.as_str());
assert_eq!(waiting_records(&setup), 0, "the push that went through cleared the record");
let after: Observed = run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]);
assert_eq!(stderr_text(&after), "");
remove_settled(&setup.fixture);
}
#[test]
fn machine_read_output_is_untouched_and_keeps_the_failure_for_a_person() {
let setup: Setup = setup("push-failure-porcelain");
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, commit.as_str());
std::fs::write(setup.repo.join("untracked.txt"), "new\n").expect("untracked");
for arguments in [&["status", "--porcelain=v2", "--branch"][..], &["status", "-z"], &["rev-parse", "HEAD"], &["log", "-1", "--format=%H"]] {
let wrapped: Observed = run_wrapped(&setup.fixture, &setup.repo, arguments);
let direct: Observed = run_direct(&setup.fixture, &setup.repo, arguments);
assert_eq!(wrapped.stdout, direct.stdout, "{arguments:?}");
assert_eq!(stderr_text(&wrapped), stderr_text(&direct), "{arguments:?}");
assert_eq!(waiting_records(&setup), 1, "{arguments:?} took the record meant for a person");
}
let human: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(human.contains(REFUSAL), "{human}");
remove_settled(&setup.fixture);
}
#[test]
fn the_notice_names_the_commit_as_git_abbreviates_it() {
let setup: Setup = setup("push-failure-abbrev");
git(&setup.fixture, &setup.repo, &["config", "core.abbrev", "12"]);
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, commit.as_str());
let text: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(text.contains(&format!("commit {}, so", &commit[..12])), "twelve characters, as core.abbrev asks: {text}");
assert!(!text.contains("your commit"), "{text}");
remove_settled(&setup.fixture);
}
fn linked_worktree(setup: &Setup, branch: &str) -> PathBuf {
let path: PathBuf = setup.fixture.root.join(format!("linked-{branch}"));
git(&setup.fixture, &setup.repo, &["worktree", "add", "--quiet", "-b", branch, path.to_str().expect("UTF-8")]);
git(&setup.fixture, &path, &["config", "maintenance.autoDetach", "false"]);
return path;
}
fn linked_git_dir(setup: &Setup, linked: &Path) -> PathBuf {
let named = git(&setup.fixture, linked, &["rev-parse", "--absolute-git-dir"]);
return PathBuf::from(String::from_utf8_lossy(&named.stdout).trim());
}
#[test]
fn a_failed_push_is_shown_in_the_worktree_that_made_the_commit() {
let setup: Setup = setup("push-failure-worktree");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
setup.service.serve(&setup.fixture, own.as_path());
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service_in(&setup, linked.as_path(), "second\n");
wait_until_settled_in(own.as_path(), commit.as_str());
assert_eq!(waiting_records_in(own.as_path()), 1, "the refusal was recorded in the worktree's own Git directory");
assert_eq!(waiting_records(&setup), 0, "nothing was recorded in the main worktree");
let elsewhere: Observed = run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]);
assert_eq!(stderr_text(&elsewhere), "", "another worktree is not shown a commit it did not make");
assert_eq!(waiting_records_in(own.as_path()), 1, "the other worktree left the record for its owner");
let owner: String = stderr_text(&run_wrapped(&setup.fixture, &linked, &["status", "--short"]));
assert!(owner.contains("branch feature") && owner.contains(REFUSAL), "{owner}");
assert_eq!(waiting_records_in(own.as_path()), 0, "shown once");
remove_settled(&setup.fixture);
}
#[test]
fn a_deadline_notice_names_a_remedy_that_reaches_the_service() {
let setup: Setup = setup_with("push-failure-deadline", &[("CLI_GIT_PUSH_DEADLINE_SECONDS", "1")]);
let slow: PathBuf = setup.fixture.root.join("slow-receive-pack");
super::support::executable(slow.as_path(), b"#!/bin/sh\nsleep 30\nexec git-receive-pack \"$@\"\n");
git(&setup.fixture, &setup.repo, &["config", "remote.origin.receivepack", slow.to_str().expect("UTF-8")]);
let commit: String = commit_through_service(&setup, "second\n");
wait_until_settled(&setup, commit.as_str());
let text: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(text.contains("did not finish within 1 seconds and was stopped"), "{text}");
assert!(text.contains("systemctl --user set-environment CLI_GIT_PUSH_DEADLINE_SECONDS="), "{text}");
assert!(text.contains(git_policy_cli::service_messages::RESTART_COMMAND), "{text}");
assert!(!text.contains("set CLI_GIT_PUSH_DEADLINE_SECONDS to allow longer"), "a variable of the terminal never reaches the service: {text}");
remove_settled(&setup.fixture);
}
fn leave_pending_push(setup: &Setup, linked: &Path, own: &Path, branch: &str) -> String {
let named = git(&setup.fixture, linked, &["rev-parse", "HEAD"]);
let commit: String = String::from_utf8_lossy(&named.stdout).trim().to_string();
let item: WorkItem = WorkItem {
kind: WorkKind::Push,
repository: linked.display().to_string(),
git_dir: own.display().to_string(),
branch_ref: format!("refs/heads/{branch}"),
oid: commit.clone(),
parent: String::new(),
created: now_iso(),
tries: 0,
};
enqueue(work_directory(own).as_path(), &item).expect("the push is recorded");
return commit;
}
#[test]
fn a_failed_push_of_a_branch_no_worktree_holds_is_shown_anywhere() {
let setup: Setup = setup("push-failure-orphan");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
setup.service.serve(&setup.fixture, own.as_path());
refuse_pushes(setup.remote.as_path());
let commit: String = commit_through_service_in(&setup, linked.as_path(), "second\n");
wait_until_settled_in(own.as_path(), commit.as_str());
let removed: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "remove", "--force", linked.to_str().expect("UTF-8")]);
assert_eq!(removed.code, Some(0), "{}", stderr_text(&removed));
let shown: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(shown.contains("branch feature") && shown.contains(REFUSAL), "{shown}");
let again: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert_eq!(again, "", "shown once");
remove_settled(&setup.fixture);
}
#[test]
fn a_push_pending_when_its_worktree_is_removed_is_reported() {
let setup: Setup = setup("push-pending-removed");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
let commit: String = leave_pending_push(&setup, linked.as_path(), own.as_path(), "feature");
let removed: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "remove", linked.to_str().expect("UTF-8")]);
assert_eq!(removed.code, Some(0), "{}", stderr_text(&removed));
assert!(!own.exists(), "the worktree is gone");
let shown: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(shown.contains("branch feature") && shown.contains(&commit[..7]) && shown.contains("removed"), "{shown}");
remove_settled(&setup.fixture);
}
#[test]
fn a_push_pending_when_its_worktree_is_pruned_is_reported() {
let setup: Setup = setup("push-pending-pruned");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
let commit: String = leave_pending_push(&setup, linked.as_path(), own.as_path(), "feature");
std::fs::remove_dir_all(&linked).expect("the worktree directory is deleted by hand");
let pruned: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "prune"]);
assert_eq!(pruned.code, Some(0), "{}", stderr_text(&pruned));
assert!(!own.exists(), "the registration is gone");
let shown: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(shown.contains("branch feature") && shown.contains(&commit[..7]) && shown.contains("removed"), "{shown}");
remove_settled(&setup.fixture);
}
#[test]
fn a_record_that_cannot_be_saved_before_a_removal_is_told_to_the_person() {
let setup: Setup = setup("push-pending-unsaved");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
leave_pending_push(&setup, linked.as_path(), own.as_path(), "feature");
let common: PathBuf = setup.repo.join(".git");
std::fs::create_dir_all(common.join("cli-git")).expect("the wrapper's directory");
std::fs::write(common.join(SNAPSHOT_DIRECTORY), "a file where the directory belongs").expect("blocker");
let removed: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "remove", linked.to_str().expect("UTF-8")]);
assert_eq!(removed.code, Some(0), "the removal the person asked for still happens: {}", stderr_text(&removed));
let said: String = stderr_text(&removed);
assert!(said.contains("could not all be saved") && said.contains(own.to_string_lossy().as_ref()), "{said}");
remove_settled(&setup.fixture);
}
#[test]
fn a_record_of_a_removed_worktree_that_cannot_be_kept_is_told_and_shown_later() {
let setup: Setup = setup("push-pending-unkept");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
let commit: String = leave_pending_push(&setup, linked.as_path(), own.as_path(), "feature");
let removed: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "remove", linked.to_str().expect("UTF-8")]);
assert_eq!(removed.code, Some(0), "{}", stderr_text(&removed));
let blocker: PathBuf = setup.repo.join(".git").join(ORPHAN_NOTICE_DIRECTORY);
std::fs::create_dir_all(blocker.parent().expect("parent")).expect("the wrapper's directory");
std::fs::write(&blocker, "a file where the directory belongs").expect("blocker");
let told: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(told.contains("had not finished when its worktree was removed, and noting that failed"), "{told}");
std::fs::remove_file(&blocker).expect("the cause is fixed");
let shown: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert!(shown.contains("branch feature") && shown.contains(&commit[..7]) && shown.contains("removed"), "{shown}");
remove_settled(&setup.fixture);
}
#[test]
fn a_refused_removal_reports_nothing_and_keeps_the_pending_push() {
let setup: Setup = setup("push-pending-kept");
let linked: PathBuf = linked_worktree(&setup, "feature");
let own: PathBuf = linked_git_dir(&setup, linked.as_path());
leave_pending_push(&setup, linked.as_path(), own.as_path(), "feature");
std::fs::write(linked.join("untracked.txt"), "not committed\n").expect("an untracked file makes the removal refuse");
let refused: Observed = run_wrapped(&setup.fixture, &setup.repo, &["worktree", "remove", linked.to_str().expect("UTF-8")]);
assert_ne!(refused.code, Some(0), "Git refuses to remove a worktree with untracked files");
let shown: String = stderr_text(&run_wrapped(&setup.fixture, &setup.repo, &["status", "--short"]));
assert_eq!(shown, "", "the worktree still exists, so nothing was lost");
assert!(own.join("cli-git/work/pending").read_dir().expect("the work directory").count() >= 1, "the pending push is still queued");
remove_settled(&setup.fixture);
}