use super::commit::{git_text, remove_settled, wrapped_commit};
use super::service_commit::runtime_for;
use super::service_landing_selection::base_repository;
use super::support::{Fixture, direct, executable, fixture, git, observe, spawn_owned, while_test_lives};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const SERVICE: &str = env!("CARGO_BIN_EXE_cli-git-service");
const PATIENCE: Duration = Duration::from_secs(60);
pub(super) struct Held(Child);
impl Drop for Held {
fn drop(&mut self) {
let _killed = self.0.kill();
let _waited = self.0.wait();
}
}
fn service_for(fixture: &Fixture, name: &str, notice_seconds: &str) -> Held {
return service_with_arguments(fixture, name, notice_seconds, &[]);
}
pub(super) fn service_with_arguments(fixture: &Fixture, name: &str, notice_seconds: &str, extra: &[&str]) -> Held {
let runtime: PathBuf = runtime_for(name);
let git_dir: PathBuf = fixture.root.join("repo/.git");
let socket: PathBuf = git_policy_cli::service_instance::socket_for(runtime.as_path(), git_dir.as_path());
std::fs::create_dir_all(socket.parent().expect("parent")).expect("socket directory");
let child: Child = Command::new(SERVICE)
.args(["--journal"])
.arg(fixture.root.join("journal"))
.arg("--path")
.arg(&socket)
.args(extra)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", fixture.root.join("home"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/nonexistent-global-config")
.env("CLI_GIT_WAIT_NOTICE_SECONDS", notice_seconds)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("the service starts");
let began: Instant = Instant::now();
while std::os::unix::net::UnixStream::connect(&socket).is_err() {
assert!(began.elapsed() < PATIENCE, "the service never listened");
std::thread::sleep(Duration::from_millis(10));
}
return Held(child);
}
fn foreign_commit_with_editor(fixture: &Fixture, repo: &Path, editor_body: &str) -> Child {
let editor: PathBuf = fixture.root.join("holding-editor");
executable(editor.as_path(), format!("#!/bin/sh\n{editor_body}printf 'foreign\\n' > \"$1\"\n").as_bytes());
git(fixture, repo, &["config", "core.editor", editor.to_str().expect("UTF-8")]);
git(fixture, repo, &["config", "core.lockfilePid", "true"]);
std::fs::write(repo.join("b.txt"), "foreign edit\n").expect("b.txt");
let child: Child = spawn_owned(direct(fixture).current_dir(repo).args(["commit", "--all", "--quiet"]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped())).expect("foreign commit");
let began: Instant = Instant::now();
while !repo.join(".git/index.lock").exists() {
assert!(began.elapsed() < PATIENCE, "the foreign commit never took the lock");
std::thread::sleep(Duration::from_millis(5));
}
return child;
}
pub(super) fn foreign_commit(fixture: &Fixture, repo: &Path, hold: u32) -> Child {
return foreign_commit_with_editor(fixture, repo, format!("sleep {hold}\n").as_str());
}
pub(super) fn foreign_commit_until_released(fixture: &Fixture, repo: &Path, hold: u32) -> (Child, PathBuf) {
let release: PathBuf = fixture.root.join("holder-release");
let body: String = format!("while [ ! -e '{}' ] && {}; do sleep 0.02; done\nsleep {hold}\n", release.display(), while_test_lives(fixture.root.as_path()));
return (foreign_commit_with_editor(fixture, repo, body.as_str()), release);
}
#[test]
fn a_commit_waits_for_a_foreign_commit_that_holds_the_index_and_lands_on_top_of_it() {
let fixture: Fixture = fixture("landing-lock-holder");
let repo: PathBuf = base_repository(&fixture);
let name: String = format!("landing-lock-{}", std::process::id());
let _service: Held = service_for(&fixture, name.as_str(), "1");
let base: String = git_text(&fixture, &repo, &["rev-parse", "HEAD"]).trim().to_string();
let holder: Child = foreign_commit(&fixture, &repo, 2);
std::fs::write(repo.join("a.txt"), "mine\n").expect("a.txt");
let mut command: Command = wrapped_commit(&fixture, &repo, &["commit", "-m", "mine", "--", "a.txt"]);
command.env("XDG_RUNTIME_DIR", runtime_for(name.as_str()));
let served = observe(&mut command, b"");
let held = holder.wait_with_output().expect("the foreign commit ends");
assert!(held.status.success(), "the holder's own commit succeeds: {}", String::from_utf8_lossy(&held.stderr));
assert_eq!(served.code, Some(0), "{}", String::from_utf8_lossy(&served.stderr));
assert_eq!(git_text(&fixture, &repo, &["log", "--reverse", "--format=%s", format!("{base}..HEAD").as_str()]), "foreign\nmine\n");
assert_eq!(git_text(&fixture, &repo, &["status", "--short"]), "", "the index shows both commits");
assert!(!repo.join(".git/index.lock").exists(), "no lock is left");
remove_settled(&fixture);
}
const RELEASED_HOLD_SECONDS: u32 = 0;
const NOTICES_BEYOND_BACKOFF: usize = 4;
#[test]
fn a_foreign_holder_proven_alive_is_waited_for_with_no_bound_and_the_commit_lands_on_top() {
let fixture: Fixture = fixture("landing-lock-no-bound");
let repo: PathBuf = base_repository(&fixture);
let name: String = format!("ll-nobound-{}", std::process::id());
let _service: Held = service_for(&fixture, name.as_str(), "1");
let base: String = git_text(&fixture, &repo, &["rev-parse", "HEAD"]).trim().to_string();
let (holder, release): (Child, PathBuf) = foreign_commit_until_released(&fixture, &repo, RELEASED_HOLD_SECONDS);
std::fs::write(repo.join("a.txt"), "mine\n").expect("a.txt");
let mut command: Command = wrapped_commit(&fixture, &repo, &["commit", "-m", "mine", "--", "a.txt"]);
command.env("XDG_RUNTIME_DIR", runtime_for(name.as_str())).env("CLI_GIT_WAIT_NOTICE_SECONDS", "1");
let watched = super::wait_notice::watch_notices(&mut command, "waiting for PID", NOTICES_BEYOND_BACKOFF, || {
std::fs::write(&release, b"").expect("the foreign commit is told to let go");
});
let held = holder.wait_with_output().expect("the foreign commit ends");
assert!(held.status.success(), "{}", String::from_utf8_lossy(&held.stderr));
assert_eq!(watched.code, Some(0), "{}", watched.stderr);
assert!(!watched.stderr.contains("was not made") && !watched.stderr.contains("giving up"), "{}", watched.stderr);
assert_eq!(git_text(&fixture, &repo, &["log", "--reverse", "--format=%s", format!("{base}..HEAD").as_str()]), "foreign\nmine\n");
assert_eq!(git_text(&fixture, &repo, &["status", "--short"]), "", "the index shows both commits");
remove_settled(&fixture);
}
const QUIET_NOTICE_SECONDS: &str = "3600";
const HOLD_AFTER_NOTICE: Duration = Duration::from_secs(8);
const CLIENT_SILENCE: Duration = Duration::from_secs(3);
struct SignalOnNotice {
told: Option<std::sync::mpsc::Sender<()>>,
text: Vec<u8>,
}
impl std::io::Write for SignalOnNotice {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.text.extend_from_slice(bytes);
if let Some(channel) = self.told.take() {
let _sent = channel.send(());
}
return Ok(bytes.len());
}
fn flush(&mut self) -> std::io::Result<()> {
return Ok(());
}
}
#[test]
fn the_service_sends_heartbeats_to_a_request_that_waits_behind_a_long_foreign_hold() {
use git_policy_cli::service_client::{Activation, ClientConfig, Submitted, submit};
use git_policy_cli::service_protocol::{ALLOWED_MODES, Change, CommitRequest};
use git_policy_cli::service_server::Bind;
let fixture: Fixture = fixture("landing-lock-heartbeat");
let repo: PathBuf = base_repository(&fixture);
let name: String = format!("ll-beat-{}", std::process::id());
let _service: Held = service_for(&fixture, name.as_str(), QUIET_NOTICE_SECONDS);
let git_dir: PathBuf = fixture.root.join("repo/.git");
let socket: PathBuf = git_policy_cli::service_instance::socket_for(runtime_for(name.as_str()).as_path(), git_dir.as_path());
let (holder, release): (Child, PathBuf) = foreign_commit_until_released(&fixture, &repo, RELEASED_HOLD_SECONDS);
std::fs::write(repo.join("a.txt"), "mine\n").expect("a.txt");
let blob: String = git_text(&fixture, &repo, &["hash-object", "-w", "--", "a.txt"]).trim().to_string();
let request: CommitRequest = CommitRequest {
id: String::from("heartbeat-landing"),
repository: repo.clone(),
common_dir: git_dir.clone(),
git_dir: git_dir.clone(),
reference: String::from("refs/heads/main"),
base: git_text(&fixture, &repo, &["rev-parse", "HEAD"]).trim().to_string(),
changes: vec![Change { path: String::from("a.txt"), mode: ALLOWED_MODES[0], blob: Some(blob) }],
message: String::from("mine\n"),
author: None,
committer: String::from("T <t@example.invalid> 1793535889 +0000"),
amend: false,
tree: None,
commit: None,
cleanup: None,
index: git_dir.join("index"),
captured: 0,
};
let config: ClientConfig = ClientConfig { address: Bind::Path(socket), activation: Activation::Managed, start_wait: PATIENCE, heartbeat_silence: CLIENT_SILENCE, start_watch: None, keep_fresh: None, answer_ceiling: git_policy_cli::wait_ceilings::ANSWER_CEILING, withdraw_ceiling: git_policy_cli::wait_ceilings::WITHDRAW_CEILING, extra_wait: git_policy_cli::wait_ceilings::SHORT_EXTRA_WAIT, start_ceiling: git_policy_cli::wait_ceilings::START_CEILING };
let (told, hearing) = std::sync::mpsc::channel::<()>();
let letting_go = std::thread::spawn(move || {
hearing.recv_timeout(PATIENCE).expect("the client was told the request waits");
std::thread::sleep(HOLD_AFTER_NOTICE);
std::fs::write(&release, b"").expect("the foreign commit is told to let go");
});
let mut notices: SignalOnNotice = SignalOnNotice { told: Some(told), text: Vec::new() };
let answered = submit(&config, &request, &mut notices);
letting_go.join().expect("releaser");
let held = holder.wait_with_output().expect("the foreign commit ends");
assert!(held.status.success(), "{}", String::from_utf8_lossy(&held.stderr));
assert!(matches!(answered, Ok(Submitted::Landed { .. })), "{answered:?} {}", String::from_utf8_lossy(¬ices.text));
assert!(!String::from_utf8_lossy(¬ices.text).contains("stopped answering"), "{}", String::from_utf8_lossy(¬ices.text));
remove_settled(&fixture);
}