use super::commit::{git_text, remove_settled, wrapped_commit};
use super::support::{Fixture, FixtureService, fixture, git, repository, start_fixture_service};
use git_policy_cli::transaction_phase::PHASE_SIGNAL_VARIABLE;
use std::ffi::OsStr;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
const PATIENCE: Duration = Duration::from_secs(60);
const FILE: &str = "f.txt";
const OTHER: &str = "g.txt";
const LINES: usize = 9;
const LATER_ARRIVALS: usize = 4;
struct Scene {
fixture: Fixture,
repo: PathBuf,
service: FixtureService,
markers: PathBuf,
}
struct Started {
child: Child,
stderr: std::thread::JoinHandle<String>,
waiting: Receiver<()>,
}
struct Ended {
code: Option<i32>,
stderr: String,
}
fn lines_with(edits: &[(usize, &str)]) -> String {
let mut text: String = String::new();
for number in 1..=LINES {
let line: String = edits.iter().find(|(at, _)| { return *at == number; }).map_or_else(|| { return format!("line {number}"); }, |(_, edit)| { return (*edit).to_string(); });
text.push_str(&line);
text.push('\n');
}
return text;
}
fn scene(name: &str, native: bool) -> Scene {
let fixture: Fixture = fixture(name);
let repo: PathBuf = repository(&fixture, OsStr::new("repo"));
if native {
let template: PathBuf = fixture.root.join("template");
std::fs::write(&template, "").expect("template");
git(&fixture, &repo, &["config", "commit.template", template.to_str().expect("UTF-8")]);
}
std::fs::write(repo.join(FILE), lines_with(&[])).expect("the file");
std::fs::write(repo.join(OTHER), "other\n").expect("the other file");
git(&fixture, &repo, &["add", "--", FILE, OTHER]);
git(&fixture, &repo, &["commit", "--quiet", "--message=base"]);
git(&fixture, &repo, &["config", "maintenance.autoDetach", "false"]);
let markers: PathBuf = fixture.root.join("markers");
std::fs::create_dir(&markers).expect("markers");
let service: FixtureService = start_fixture_service(&fixture);
service.serve(&fixture, repo.join(".git").as_path());
return Scene { fixture, repo, service, markers };
}
fn start(scene: &Scene, message: &str, paused: bool) -> Started {
return start_on(scene, message, FILE, paused.then_some(scene.markers.as_path()));
}
fn start_on(scene: &Scene, message: &str, path: &str, markers: Option<&Path>) -> Started {
let mut command: Command = wrapped_commit(&scene.fixture, &scene.repo, &["commit", "--quiet", "-m", message, "--", path]);
command.env("XDG_RUNTIME_DIR", &scene.service.runtime).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
if let Some(directory) = markers {
command.env(PHASE_SIGNAL_VARIABLE, format!("service-prepared:pause:{}", directory.display()));
}
let mut child: Child = command.spawn().expect("the wrapped commit starts");
let stderr = child.stderr.take().expect("piped standard error");
let (sender, waiting) = channel::<()>();
let reader = std::thread::spawn(move || {
let mut all: String = String::new();
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
if line.contains("waiting for") {
let _told = sender.send(());
}
all.push_str(&line);
all.push('\n');
}
return all;
});
return Started { child, stderr: reader, waiting };
}
fn finish(mut started: Started) -> Ended {
let status = started.child.wait().expect("the commit ends");
return Ended { code: status.code(), stderr: started.stderr.join().expect("the reader") };
}
fn wait_for(what: &str, present: impl Fn() -> bool) {
let began: Instant = Instant::now();
while !present() {
assert!(began.elapsed() < PATIENCE, "never happened: {what}");
std::thread::sleep(Duration::from_millis(5));
}
}
fn run(scene: &Scene, first: &[(usize, &str)], second: &[(usize, &str)], late: &[(usize, &str)]) -> (Ended, Ended) {
let file: PathBuf = scene.repo.join(FILE);
let lock: PathBuf = scene.repo.join(".git/index.lock");
for number in 2..=LATER_ARRIVALS {
std::fs::write(scene.markers.join(format!("service-prepared-{number}.release")), "").expect("release of a later arrival");
}
std::fs::write(&lock, "").expect("the index lock");
std::fs::write(&file, lines_with(first)).expect("first edit");
let one: Started = start(scene, "one", false);
one.waiting.recv_timeout(PATIENCE).expect("the first commit waits for the index");
std::fs::write(&file, lines_with(second)).expect("second edit");
let two: Started = start(scene, "two", true);
let reached: PathBuf = scene.markers.join("service-prepared-1.reached");
wait_for("the second commit prepared", || { return reached.exists(); });
std::fs::write(&file, lines_with(late)).expect("late edit");
std::fs::write(scene.markers.join("service-prepared-1.release"), "").expect("release");
std::fs::remove_file(&lock).expect("the lock is let go");
let first_end: Ended = finish(one);
let second_end: Ended = finish(two);
return (first_end, second_end);
}
fn show(scene: &Scene, revision: &str) -> String {
return git_text(&scene.fixture, &scene.repo, &["show", revision]);
}
#[test]
fn a_commit_that_waits_behind_another_lands_its_captured_lines_merged_onto_it() {
let scene: Scene = scene("replay-merge", false);
let first: [(usize, &str); 1] = [(1, "first edit")];
let second: [(usize, &str); 1] = [(7, "second edit")];
let late: [(usize, &str); 2] = [(4, "late edit"), (7, "second edit")];
let (one, two) = run(&scene, &first, &second, &late);
assert_eq!(one.code, Some(0), "{}", one.stderr);
assert_eq!(two.code, Some(0), "{}", two.stderr);
let landed: String = show(&scene, &format!("HEAD:{FILE}"));
assert_eq!(landed, lines_with(&[(1, "first edit"), (7, "second edit")]), "both edits landed and the late write did not");
assert_eq!(git_text(&scene.fixture, &scene.repo, &["log", "--format=%s", "-3"]), "two\none\nbase\n");
let second_change: String = git_text(&scene.fixture, &scene.repo, &["diff", "--numstat", "HEAD~1", "HEAD"]);
assert_eq!(second_change, format!("1\t1\t{FILE}\n"), "the second commit changes its own line only");
assert_eq!(std::fs::read_to_string(scene.repo.join(FILE)).expect("the file"), lines_with(&late), "the person's file is intact");
remove_settled(&scene.fixture);
}
#[test]
fn a_commit_git_builds_that_waits_behind_another_lands_its_captured_lines_merged_onto_it() {
let scene: Scene = scene("replay-native", true);
let first: [(usize, &str); 1] = [(1, "first edit")];
let second: [(usize, &str); 1] = [(7, "second edit")];
let late: [(usize, &str); 2] = [(4, "late edit"), (7, "second edit")];
let (one, two) = run(&scene, &first, &second, &late);
assert_eq!(one.code, Some(0), "{}", one.stderr);
assert_eq!(two.code, Some(0), "{}", two.stderr);
assert_eq!(show(&scene, &format!("HEAD:{FILE}")), lines_with(&[(1, "first edit"), (7, "second edit")]), "both edits landed and the late write did not");
assert_eq!(git_text(&scene.fixture, &scene.repo, &["log", "--format=%s", "-3"]), "two\none\nbase\n");
assert_eq!(std::fs::read_to_string(scene.repo.join(FILE)).expect("the file"), lines_with(&late), "the person's file is intact");
remove_settled(&scene.fixture);
}
#[test]
fn the_later_capture_of_one_worktree_lands_its_bytes_over_an_overlapping_earlier_one() {
let scene: Scene = scene("replay-order", false);
let first: [(usize, &str); 1] = [(1, "first edit")];
let second: [(usize, &str); 1] = [(1, "second edit")];
let late: [(usize, &str); 2] = [(1, "second edit"), (4, "late edit")];
let (one, two) = run(&scene, &first, &second, &late);
assert_eq!(one.code, Some(0), "{}", one.stderr);
assert_eq!(two.code, Some(0), "{}", two.stderr);
assert_eq!(show(&scene, &format!("HEAD:{FILE}")), lines_with(&second), "the bytes the second command captured landed, not the late write");
assert_eq!(git_text(&scene.fixture, &scene.repo, &["log", "--format=%s", "-3"]), "two\none\nbase\n");
assert_eq!(std::fs::read_to_string(scene.repo.join(FILE)).expect("the file"), lines_with(&late), "the person's file is intact");
remove_settled(&scene.fixture);
}
#[test]
fn overlapping_edits_of_a_commit_made_elsewhere_fail_the_second_commit_with_nothing_changed_and_its_message_saved() {
let scene: Scene = scene("replay-conflict", false);
let file: PathBuf = scene.repo.join(FILE);
let second: [(usize, &str); 1] = [(1, "second edit")];
let late: [(usize, &str); 2] = [(1, "second edit"), (4, "late edit")];
for number in 2..=LATER_ARRIVALS {
std::fs::write(scene.markers.join(format!("service-prepared-{number}.release")), "").expect("release of a later arrival");
}
std::fs::write(&file, lines_with(&second)).expect("second edit");
let waiting: Started = start(&scene, "two", true);
let reached: PathBuf = scene.markers.join("service-prepared-1.reached");
wait_for("the second commit prepared", || { return reached.exists(); });
std::fs::write(&file, lines_with(&[(1, "foreign edit")])).expect("foreign edit");
git(&scene.fixture, &scene.repo, &["commit", "--quiet", "--message=one", "--", FILE]);
std::fs::write(&file, lines_with(&late)).expect("late edit");
std::fs::write(scene.markers.join("service-prepared-1.release"), "").expect("release");
let two: Ended = finish(waiting);
assert_eq!(two.code, Some(1), "{}", two.stderr);
let short: String = git_text(&scene.fixture, &scene.repo, &["log", "-1", "--format=%h"]).trim().to_string();
assert_eq!(git_text(&scene.fixture, &scene.repo, &["log", "--format=%s", "-2"]), "one\nbase\n", "only the other commit landed");
let text: String = two.stderr.lines().filter(|line| { return !line.starts_with("[warn] "); }).collect::<Vec<&str>>().join("\n");
assert!(text.contains(FILE), "the file is named: {text}");
assert!(text.contains(short.as_str()) && text.contains("one"), "the commit that landed first is named: {text}");
let saved: Vec<PathBuf> = std::fs::read_dir(scene.repo.join(".git"))
.expect("the git directory")
.flatten()
.map(|entry| { return entry.path(); })
.filter(|path| { return path.file_name().is_some_and(|name| { return name.to_string_lossy().starts_with("cli-git-saved-message-"); }); })
.collect();
assert_eq!(saved.len(), 1, "the message is saved once: {saved:?}");
assert!(text.contains(saved[0].to_string_lossy().as_ref()), "the saved message is named: {text}");
assert_eq!(std::fs::read_to_string(&saved[0]).expect("the saved message").trim_end(), "two");
assert_eq!(git_text(&scene.fixture, &scene.repo, &["diff", "--cached", "--raw"]), "", "the index matches the branch the other commit left");
assert_eq!(std::fs::read_to_string(&file).expect("the file"), lines_with(&late), "the person's file is intact");
assert!(!scene.repo.join(".git/index.lock").exists(), "no lock is left");
remove_settled(&scene.fixture);
}
fn holds_socket(marker: &Path) -> bool {
let needle: Vec<u8> = marker.to_string_lossy().as_bytes().to_vec();
let Ok(processes) = std::fs::read_dir("/proc") else { return false };
return processes.flatten().any(|process| {
let Ok(environment) = std::fs::read(process.path().join("environ")) else { return false };
if !environment.windows(needle.len()).any(|window| { return window == needle.as_slice(); }) {
return false;
}
let Ok(descriptors) = std::fs::read_dir(process.path().join("fd")) else { return false };
return descriptors.flatten().any(|descriptor| { return std::fs::read_link(descriptor.path()).is_ok_and(|target| { return target.to_string_lossy().starts_with("socket:"); }); });
});
}
fn run_one_batch(scene: &Scene, later_arrives_first: bool) -> (Ended, Ended, Ended) {
let file: PathBuf = scene.repo.join(FILE);
let lock: PathBuf = scene.repo.join(".git/index.lock");
let earlier_markers: PathBuf = scene.markers.join("earlier");
let later_markers: PathBuf = scene.markers.join("later");
for directory in [&earlier_markers, &later_markers] {
std::fs::create_dir(directory).expect("markers of one commit");
for number in 2..=LATER_ARRIVALS {
std::fs::write(directory.join(format!("service-prepared-{number}.release")), "").expect("release of a later arrival");
}
}
std::fs::write(&lock, "").expect("the index lock");
std::fs::write(scene.repo.join(OTHER), "other edit\n").expect("edit of the other file");
let holder: Started = start_on(scene, "holder", OTHER, None);
holder.waiting.recv_timeout(PATIENCE).expect("the first commit waits for the index");
std::fs::write(&file, lines_with(&[(1, "earlier edit")])).expect("earlier edit");
let earlier: Started = start_on(scene, "earlier", FILE, Some(earlier_markers.as_path()));
wait_for("the earlier commit captured", || { return earlier_markers.join("service-prepared-1.reached").exists(); });
std::fs::write(&file, lines_with(&[(1, "earlier edit"), (5, "later edit")])).expect("later edit");
let later: Started = start_on(scene, "later", FILE, Some(later_markers.as_path()));
wait_for("the later commit captured", || { return later_markers.join("service-prepared-1.reached").exists(); });
let order: [&Path; 2] = if later_arrives_first { [later_markers.as_path(), earlier_markers.as_path()] } else { [earlier_markers.as_path(), later_markers.as_path()] };
for directory in order {
std::fs::write(directory.join("service-prepared-1.release"), "").expect("release");
wait_for("the commit connected to the service", || { return holds_socket(directory); });
std::thread::sleep(Duration::from_millis(300));
}
std::fs::remove_file(&lock).expect("the lock is let go");
return (finish(holder), finish(earlier), finish(later));
}
#[test]
fn an_earlier_capture_that_arrives_second_in_one_batch_does_not_replace_the_later_one() {
let scene: Scene = scene("batch-inverted", false);
let (holder, earlier, later) = run_one_batch(&scene, true);
assert_eq!(holder.code, Some(0), "{}", holder.stderr);
assert_eq!(later.code, Some(0), "{}", later.stderr);
assert_eq!(show(&scene, &format!("HEAD:{FILE}")), lines_with(&[(1, "earlier edit"), (5, "later edit")]), "the later edit is on the branch");
assert_eq!(earlier.code, Some(1), "{}", earlier.stderr);
assert!(earlier.stderr.contains("concurrent-commit/already-committed"), "{}", earlier.stderr);
remove_settled(&scene.fixture);
}
#[test]
fn captures_that_arrive_in_order_in_one_batch_both_land() {
let scene: Scene = scene("batch-in-order", false);
let (holder, earlier, later) = run_one_batch(&scene, false);
for ended in [&holder, &earlier, &later] {
assert_eq!(ended.code, Some(0), "{}", ended.stderr);
}
assert_eq!(show(&scene, &format!("HEAD:{FILE}")), lines_with(&[(1, "earlier edit"), (5, "later edit")]));
assert_eq!(git_text(&scene.fixture, &scene.repo, &["log", "--format=%s", "-4"]), "later\nearlier\nholder\nbase\n");
remove_settled(&scene.fixture);
}