use super::support::{Fixture, Observed, direct, fixture, git, remove, repository, run_wrapped};
use git_policy_cli::pid_namespace::current_pid_namespace;
use git_policy_cli::stale_ref_lock_judge::{Judge, MARGIN};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::time::{Duration, Instant};
const PATIENCE: Duration = Duration::from_secs(60);
const GONE_REF: &str = "refs/remotes/origin/gone";
const HOLD_HOOK: &str = "#!/bin/sh\nif [ \"$1\" = prepared ] && [ -n \"$STALE_LOCK_READY\" ]; then\n echo $$ > \"$STALE_LOCK_READY.part\"\n mv \"$STALE_LOCK_READY.part\" \"$STALE_LOCK_READY\"\n exec sleep 600\nfi\nexit 0\n";
struct Held {
child: Child,
_input: Option<ChildStdin>,
hook: Option<i32>,
}
impl Held {
fn kill(&mut self) {
self.child.kill().expect("SIGKILL the Git");
self.child.wait().expect("reap the Git");
if let Some(hook) = self.hook.take() {
unsafe { libc::kill(hook, libc::SIGKILL); }
}
}
}
impl Drop for Held {
fn drop(&mut self) {
let _killed = self.child.kill();
let _reaped = self.child.wait();
if let Some(hook) = self.hook.take() {
unsafe { libc::kill(hook, libc::SIGKILL); }
}
}
}
struct Stage {
fixture: Fixture,
repo: PathBuf,
}
impl Stage {
fn git_dir(&self) -> PathBuf {
return self.repo.join(".git");
}
fn gone_lock(&self) -> PathBuf {
return self.git_dir().join(format!("{GONE_REF}.lock"));
}
}
fn stage(name: &str, lockfile_pid: bool) -> Stage {
let fixture: Fixture = fixture(name);
let repo: PathBuf = repository(&fixture, std::ffi::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")]);
git(&fixture, repo.as_path(), &["remote", "add", "origin", remote.to_str().expect("UTF-8")]);
git(&fixture, repo.as_path(), &["branch", "gone"]);
git(&fixture, repo.as_path(), &["push", "--quiet", "origin", "main", "gone"]);
git(&fixture, repo.as_path(), &["fetch", "--quiet", "origin"]);
git(&fixture, repo.as_path(), &["pack-refs", "--all"]);
git(&fixture, remote.as_path(), &["branch", "--delete", "--force", "gone"]);
if lockfile_pid {
git(&fixture, repo.as_path(), &["config", "core.lockfilePid", "true"]);
}
return Stage { fixture, repo };
}
fn wait_for(what: &str, condition: impl Fn() -> bool) {
let started: Instant = Instant::now();
while !condition() {
assert!(started.elapsed() < PATIENCE, "gave up waiting for {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
fn hold_fetch(stage: &Stage) -> Held {
let ready: PathBuf = stage.fixture.root.join("ready");
let hook: PathBuf = stage.git_dir().join("hooks").join("reference-transaction");
std::fs::write(hook.as_path(), HOLD_HOOK).expect("hook");
std::fs::set_permissions(hook.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("hook mode");
let mut command: Command = direct(&stage.fixture);
command.current_dir(stage.repo.as_path()).args(["fetch", "--prune", "--quiet", "origin"]).env("STALE_LOCK_READY", ready.as_os_str());
let child: Child = command.spawn().expect("start git fetch");
let mut held: Held = Held { child, _input: None, hook: None };
wait_for("the fetch to take its locks", || { return ready.exists(); });
held.hook = std::fs::read_to_string(ready.as_path()).expect("hook pid").trim().parse::<i32>().ok();
return held;
}
fn hold_transaction(stage: &Stage, commands: &[&str]) -> Held {
let mut command: Command = direct(&stage.fixture);
command.current_dir(stage.repo.as_path()).args(["update-ref", "--stdin"]).stdin(Stdio::piped()).stdout(Stdio::piped());
let mut child: Child = command.spawn().expect("start git update-ref");
let mut input: ChildStdin = child.stdin.take().expect("input");
let output = child.stdout.take().expect("output");
input.write_all(format!("start\n{}\nprepare\n", commands.join("\n")).as_bytes()).expect("send");
input.flush().expect("flush");
let (tell, hear) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
for line in BufReader::new(output).lines().map_while(Result::ok) {
if line == "prepare: ok" {
let _told = tell.send(());
}
}
});
let held: Held = Held { child, _input: Some(input), hook: None };
hear.recv_timeout(PATIENCE).expect("the transaction to be prepared");
return held;
}
fn age_past_margin() {
std::thread::sleep(MARGIN + Duration::from_millis(500));
}
fn wrapped_fetch(stage: &Stage) -> Observed {
let _removed = std::fs::remove_file(stage.git_dir().join("hooks").join("reference-transaction"));
return run_wrapped(&stage.fixture, stage.repo.as_path(), &["fetch", "--prune", "--quiet", "origin"]);
}
fn stderr_text(seen: &Observed) -> String {
return String::from_utf8_lossy(seen.stderr.as_slice()).into_owned();
}
fn proof_available() -> bool {
return Judge::here().pid_view_complete && current_pid_namespace().is_some();
}
#[test]
fn a_fetch_after_a_killed_fetch_clears_the_locks_where_the_holder_is_provably_gone() {
let stage: Stage = stage("stale-ref-locks-killed-fetch", true);
let mut held: Held = hold_fetch(&stage);
assert!(stage.gone_lock().exists(), "the fetch holds the lock before it is killed");
held.kill();
age_past_margin();
let seen: Observed = wrapped_fetch(&stage);
let words: String = stderr_text(&seen);
if proof_available() {
assert_eq!(seen.code, Some(0), "{words}");
assert!(words.contains("cli-git: removed the reference lock"), "{words}");
assert!(!stage.gone_lock().exists());
} else {
assert_ne!(seen.code, Some(0), "{words}");
assert!(words.contains("cli-git: left the reference lock"), "{words}");
assert!(words.contains("rm -- "), "{words}");
assert!(stage.gone_lock().exists());
}
remove(&stage.fixture);
}
#[test]
fn a_fetch_after_a_killed_fetch_with_no_pid_file_keeps_the_lock_and_gives_the_command() {
let stage: Stage = stage("stale-ref-locks-no-pid-file", false);
let mut held: Held = hold_fetch(&stage);
held.kill();
age_past_margin();
let seen: Observed = wrapped_fetch(&stage);
let words: String = stderr_text(&seen);
assert_ne!(seen.code, Some(0), "{words}");
assert!(words.contains("cli-git: left the reference lock"), "{words}");
assert!(words.contains(&format!("cli-git: rm -- {}", stage.gone_lock().display())) || words.contains("cli-git: rm -- "), "{words}");
assert!(words.contains("cannot lock ref") || words.contains("Unable to create"), "Git's own refusal follows: {words}");
assert!(stage.gone_lock().exists());
remove(&stage.fixture);
}
#[test]
fn a_fetch_leaves_the_locks_of_a_git_that_is_still_running() {
let stage: Stage = stage("stale-ref-locks-running", true);
let held: Held = hold_transaction(&stage, &["delete refs/remotes/origin/gone"]);
age_past_margin();
let seen: Observed = wrapped_fetch(&stage);
let words: String = stderr_text(&seen);
assert!(stage.gone_lock().exists(), "{words}");
assert!(!words.contains("cli-git: removed"), "{words}");
drop(held);
remove(&stage.fixture);
}
#[test]
fn another_command_gets_no_notice_and_clears_nothing() {
let stage: Stage = stage("stale-ref-locks-other-command", true);
let mut held: Held = hold_fetch(&stage);
held.kill();
age_past_margin();
let seen: Observed = run_wrapped(&stage.fixture, stage.repo.as_path(), &["status", "--short"]);
assert!(!stderr_text(&seen).contains("reference lock"), "{}", stderr_text(&seen));
assert!(stage.gone_lock().exists());
remove(&stage.fixture);
}