use super::*;
use crate::merge_isolation_journal::{intent_path, write_merge_intent};
use crate::test_support::{fixture, git, git_context, git_text, remove, repository};
use crate::transaction_owner::{TransactionOwner, current_transaction_owner};
struct World {
parent: PathBuf,
repo: PathBuf,
git_dir: PathBuf,
old_oid: String,
new_oid: String,
}
fn world(name: &str) -> World {
let parent: PathBuf = fixture(name);
let repo: PathBuf = repository(parent.as_path(), "repo");
for file in ["u.txt", "f.txt", "gone.txt", "h.txt"] {
std::fs::write(repo.join(file), format!("{file} base\n")).expect("file");
}
git(repo.as_path(), &["add", "--", "u.txt", "f.txt", "gone.txt", "h.txt"]);
git(repo.as_path(), &["commit", "--quiet", "--message=base"]);
git(repo.as_path(), &["switch", "--quiet", "--create", "feature"]);
std::fs::write(repo.join("f.txt"), b"f.txt feature\n").expect("file");
std::fs::create_dir(repo.join("dir")).expect("directory");
std::fs::write(repo.join("dir/new.txt"), b"new\n").expect("file");
git(repo.as_path(), &["rm", "--quiet", "--", "gone.txt"]);
git(repo.as_path(), &["add", "--", "f.txt", "dir/new.txt"]);
git(repo.as_path(), &["commit", "--quiet", "--message=feature"]);
git(repo.as_path(), &["switch", "--quiet", "main"]);
std::fs::write(repo.join("h.txt"), b"h.txt main\n").expect("file");
git(repo.as_path(), &["commit", "--quiet", "--all", "--message=main"]);
let old_oid: String = git_text(repo.as_path(), &["rev-parse", "HEAD"]);
let feature: String = git_text(repo.as_path(), &["rev-parse", "feature"]);
let tree: String = git_text(repo.as_path(), &["merge-tree", "--write-tree", old_oid.as_str(), feature.as_str()]);
let new_oid: String = git_text(
repo.as_path(),
&["commit-tree", tree.as_str(), "-p", old_oid.as_str(), "-p", feature.as_str(), "-m", "merge"],
);
let git_dir: PathBuf = repo.join(".git");
return World { parent, repo, git_dir, old_oid, new_oid };
}
fn place(world: &World) -> RecoveryPlace {
return RecoveryPlace { git: git_context(), cwd: world.repo.clone(), common_dir: world.git_dir.clone() };
}
fn dead_owner(id: &str) -> TransactionOwner {
let mut owner: TransactionOwner = current_transaction_owner(id, "2026-10-29T00:00:00.000Z").expect("owner");
owner.owner_identity = String::from("linux:1");
return owner;
}
fn record_of(world: &World, owner: TransactionOwner, target_ref: Option<&str>) -> PathBuf {
let intent: MergeIntent = MergeIntent {
owner,
repository_root: world.repo.to_string_lossy().into_owned(),
target_ref: target_ref.map(String::from),
old_oid: world.old_oid.clone(),
new_oid: world.new_oid.clone(),
};
return write_merge_intent(world.git_dir.as_path(), &intent).expect("the record");
}
fn snapshot(world: &World) -> (String, String) {
return (
git_text(world.repo.as_path(), &["ls-files", "--stage"]),
git_text(world.repo.as_path(), &["status", "--porcelain=v1", "--untracked-files=all"]),
);
}
#[test]
fn a_merge_that_never_began_is_discarded() {
let world: World = world("merge-recovery-never-began");
std::fs::write(world.repo.join("u.txt"), b"u.txt staged by another session\n").expect("file");
git(world.repo.as_path(), &["add", "--", "u.txt"]);
let before = snapshot(&world);
let record: PathBuf = record_of(&world, dead_owner("11111111-0000-4000-8000-000000000001"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes, vec![MergeRecoveryOutcome { record: record.clone(), action: MergeRecoveryAction::Discarded }]);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.old_oid);
assert_eq!(snapshot(&world), before);
remove(world.parent.as_path());
}
#[test]
fn a_live_owners_record_is_left_alone() {
let world: World = world("merge-recovery-live");
let live: TransactionOwner =
current_transaction_owner("22222222-0000-4000-8000-000000000002", "2026-10-29T00:00:00.000Z").expect("owner");
let record: PathBuf = record_of(&world, live, Some("refs/heads/main"));
assert!(!merge_intents_to_recover(world.git_dir.as_path()).expect("scan"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::OwnerActive);
assert!(record.exists());
remove(world.parent.as_path());
}
#[test]
fn only_dead_or_unreadable_records_need_recovery() {
let world: World = world("merge-recovery-scan");
assert!(!merge_intents_to_recover(world.git_dir.as_path()).expect("no directory"));
let record: PathBuf = record_of(&world, dead_owner("33333333-0000-4000-8000-000000000003"), None);
assert!(merge_intents_to_recover(world.git_dir.as_path()).expect("dead owner"));
std::fs::write(record.as_path(), b"{").expect("damage");
assert!(merge_intents_to_recover(world.git_dir.as_path()).expect("malformed"));
let error = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect_err("fails closed");
assert!(error.0.contains("malformed") && error.0.contains(record.to_string_lossy().as_ref()), "{error}");
assert!(record.exists(), "a record that cannot be read is kept");
remove(world.parent.as_path());
}
#[test]
fn a_landed_merge_only_loses_its_record() {
let world: World = world("merge-recovery-landed");
git(world.repo.as_path(), &["merge", "--ff-only", "--quiet", world.new_oid.as_str()]);
let record: PathBuf = record_of(&world, dead_owner("44444444-0000-4000-8000-000000000004"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::AlreadyLanded);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1"]), "");
remove(world.parent.as_path());
}
#[test]
fn a_branch_that_moved_before_the_checkout_changed_only_loses_its_record() {
let world: World = world("merge-recovery-moved");
std::fs::write(world.repo.join("h.txt"), b"h.txt again\n").expect("file");
git(world.repo.as_path(), &["commit", "--quiet", "--all", "--message=elsewhere"]);
let moved: String = git_text(world.repo.as_path(), &["rev-parse", "main"]);
let record: PathBuf = record_of(&world, dead_owner("55555555-0000-4000-8000-000000000005"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::BranchMoved);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), moved);
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1"]), "");
remove(world.parent.as_path());
}
#[test]
fn a_branch_that_moved_after_the_install_puts_the_merged_paths_back() {
let world: World = world("merge-recovery-moved-after-install");
std::fs::write(world.repo.join("u.txt"), b"u.txt staged by another session\n").expect("file");
git(world.repo.as_path(), &["add", "--", "u.txt"]);
let staged_entry: String = git_text(world.repo.as_path(), &["ls-files", "--stage", "--", "u.txt"]);
std::fs::write(world.repo.join("z.txt"), b"committed meanwhile\n").expect("file");
git(world.repo.as_path(), &["add", "--", "z.txt"]);
git(world.repo.as_path(), &["commit", "--quiet", "--message=meanwhile", "--", "z.txt"]);
let moved: String = git_text(world.repo.as_path(), &["rev-parse", "main"]);
git(world.repo.as_path(), &["read-tree", "-m", "-u", world.old_oid.as_str(), world.new_oid.as_str()]);
assert!(world.repo.join("dir/new.txt").exists(), "the merge was installed");
let record: PathBuf = record_of(&world, dead_owner("77777777-0000-4000-8000-000000000007"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::BranchMoved);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), moved, "the branch is the winner's");
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1"]), "M u.txt", "only the other session's work differs from the head");
assert_eq!(git_text(world.repo.as_path(), &["ls-files", "--stage", "--", "u.txt"]), staged_entry);
assert!(!world.repo.join("dir").exists() && world.repo.join("gone.txt").exists(), "the merge's changes are put back");
assert_eq!(std::fs::read(world.repo.join("f.txt")).expect("file"), b"f.txt base\n");
assert!(world.repo.join("z.txt").exists(), "the winner's file stays");
remove(world.parent.as_path());
}
#[test]
fn a_merge_killed_after_the_checkout_update_is_completed() {
let world: World = world("merge-recovery-after-checkout");
std::fs::write(world.repo.join("u.txt"), b"u.txt staged by another session\n").expect("file");
git(world.repo.as_path(), &["add", "--", "u.txt"]);
let staged_entry: String = git_text(world.repo.as_path(), &["ls-files", "--stage", "--", "u.txt"]);
git(world.repo.as_path(), &["read-tree", "-m", "-u", world.old_oid.as_str(), world.new_oid.as_str()]);
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.old_oid);
let record: PathBuf = record_of(&world, dead_owner("66666666-0000-4000-8000-000000000006"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Completed);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.new_oid);
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1"]), "M u.txt");
assert_eq!(git_text(world.repo.as_path(), &["ls-files", "--stage", "--", "u.txt"]), staged_entry);
assert!(!world.repo.join("gone.txt").exists());
assert_eq!(std::fs::read(world.repo.join("dir/new.txt")).expect("added file"), b"new\n");
remove(world.parent.as_path());
}
#[test]
fn a_merge_killed_between_the_worktree_and_the_index_is_completed() {
let world: World = world("merge-recovery-before-index");
std::fs::write(world.repo.join("f.txt"), b"f.txt feature\n").expect("the file Git wrote");
std::fs::remove_file(world.repo.join("gone.txt")).expect("the file Git removed");
let record: PathBuf = record_of(&world, dead_owner("77777777-0000-4000-8000-000000000007"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Completed);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.new_oid);
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1", "--untracked-files=all"]), "");
assert_eq!(std::fs::read(world.repo.join("dir/new.txt")).expect("added file"), b"new\n");
remove(world.parent.as_path());
}
#[test]
fn a_foreign_edit_stops_the_completion() {
let world: World = world("merge-recovery-foreign");
git(world.repo.as_path(), &["read-tree", "-m", "-u", world.old_oid.as_str(), world.new_oid.as_str()]);
std::fs::write(world.repo.join("f.txt"), b"someone else's edit\n").expect("foreign edit");
let record: PathBuf = record_of(&world, dead_owner("88888888-0000-4000-8000-000000000008"), Some("refs/heads/main"));
let error = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect_err("fails closed");
assert!(error.0.contains("f.txt") && error.0.contains("Nothing was changed"), "{error}");
assert!(record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.old_oid);
assert_eq!(std::fs::read(world.repo.join("f.txt")).expect("file"), b"someone else's edit\n");
remove(world.parent.as_path());
}
#[test]
fn an_unrelated_edit_does_not_make_a_merge_that_never_began_complete() {
let world: World = world("merge-recovery-edit-only");
std::fs::write(world.repo.join("f.txt"), b"an edit made while the merge was dead\n").expect("edit");
let record: PathBuf = record_of(&world, dead_owner("99999999-0000-4000-8000-000000000009"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Discarded);
assert!(!record.exists());
assert_eq!(std::fs::read(world.repo.join("f.txt")).expect("file"), b"an edit made while the merge was dead\n");
remove(world.parent.as_path());
}
#[test]
fn a_staged_change_of_a_merged_path_is_not_a_started_merge() {
let world: World = world("merge-recovery-staged-same-path");
std::fs::write(world.repo.join("f.txt"), b"f.txt staged by another session\n").expect("file");
git(world.repo.as_path(), &["add", "--", "f.txt"]);
let before = snapshot(&world);
let record: PathBuf = record_of(&world, dead_owner("cccccccc-0000-4000-8000-00000000000c"), Some("refs/heads/main"));
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Discarded);
assert!(!record.exists());
assert_eq!(snapshot(&world), before);
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.old_oid);
remove(world.parent.as_path());
}
#[test]
fn a_detached_head_is_completed_too() {
let world: World = world("merge-recovery-detached");
git(world.repo.as_path(), &["switch", "--quiet", "--detach", "HEAD"]);
git(world.repo.as_path(), &["read-tree", "-m", "-u", world.old_oid.as_str(), world.new_oid.as_str()]);
let record: PathBuf = record_of(&world, dead_owner("aaaaaaaa-0000-4000-8000-00000000000a"), None);
let outcomes = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Completed);
assert!(!record.exists());
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "HEAD"]), world.new_oid);
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "main"]), world.old_oid);
assert_eq!(git_text(world.repo.as_path(), &["status", "--porcelain=v1"]), "");
remove(world.parent.as_path());
}
#[test]
fn a_failed_merge_settles_without_a_liveness_check() {
let world: World = world("merge-recovery-settle");
let live: TransactionOwner =
current_transaction_owner("bbbbbbbb-0000-4000-8000-00000000000b", "2026-10-29T00:00:00.000Z").expect("owner");
let record: PathBuf = record_of(&world, live.clone(), Some("refs/heads/main"));
let intent: MergeIntent = parse_merge_intent(std::fs::read(record.as_path()).expect("read").as_slice()).expect("intent");
assert_eq!(intent_path(world.git_dir.as_path(), live.transaction_id.as_str()), record);
let action = settle_failed_merge(&place(&world), record.as_path(), &intent).expect("settled");
assert_eq!(action, MergeRecoveryAction::Discarded);
assert!(!record.exists());
remove(world.parent.as_path());
}
#[test]
fn the_recovery_checks_files_out_without_quiet() {
let world: World = world("merge-recovery-no-quiet");
std::fs::write(world.repo.join("f.txt"), b"f.txt feature\n").expect("the file Git wrote");
std::fs::remove_file(world.repo.join("gone.txt")).expect("the file Git removed");
let _record: PathBuf = record_of(&world, dead_owner("88888888-0000-4000-8000-000000000008"), Some("refs/heads/main"));
let (mut recording, log) = crate::recording_git::recording_context(world.parent.as_path());
recording.common_dir = Some(world.git_dir.clone());
let recording_place: RecoveryPlace = RecoveryPlace { git: recording, cwd: world.repo.clone(), common_dir: world.git_dir.clone() };
let outcomes = recover_interrupted_merges(&recording_place, world.git_dir.as_path()).expect("recovery");
assert_eq!(outcomes[0].action, MergeRecoveryAction::Completed);
let checkouts: Vec<String> = crate::recording_git::recorded_starts(log.as_path()).into_iter().filter(|start| { return start.contains("checkout-index"); }).collect();
assert_eq!(checkouts.len(), 1, "one start checks the files out: {checkouts:?}");
assert_eq!(crate::recording_git::quiet_starts(log.as_path()), Vec::<String>::new(), "no start carries --quiet or -q");
remove(world.parent.as_path());
}
#[test]
fn an_old_record_of_another_namespace_is_refused_and_named_and_nothing_changes() {
let world: World = world("merge-recovery-foreign-expired");
let mut owner: TransactionOwner = dead_owner("11111111-0000-4000-8000-000000000042");
owner.owner_pid_namespace = Some(String::from("pid:[4026531999]"));
owner.owner_pid = 31337;
let record: PathBuf = record_of(&world, owner, Some("refs/heads/main"));
std::fs::File::options().write(true).open(record.as_path()).expect("open").set_modified(std::time::SystemTime::now() - crate::pid_namespace::FOREIGN_OWNER_PATIENCE - std::time::Duration::from_secs(300)).expect("age the record");
let before = snapshot(&world);
let head: String = git_text(world.repo.as_path(), &["rev-parse", "HEAD"]);
let refused = recover_interrupted_merges(&place(&world), world.git_dir.as_path()).expect_err("an expired foreign record is refused");
let text: String = refused.0;
assert!(text.contains(record.to_string_lossy().as_ref()), "the record file is named: {text}");
assert!(text.contains("31337") && text.contains("pid:[4026531999]"), "the owner and its namespace are named: {text}");
assert!(text.contains("minutes old") && text.contains("another container") && text.contains("rm -- "), "age, the live-owner possibility and the rm: {text}");
assert!(text.contains("left running"), "the condition covers Git processes the wrapper left running, not only a cli-git command: {text}");
assert!(record.exists(), "the record is untouched");
assert_eq!(git_text(world.repo.as_path(), &["rev-parse", "HEAD"]), head);
assert_eq!(snapshot(&world), before);
assert!(merge_intents_to_recover(world.git_dir.as_path()).is_err(), "the check that decides whether recovery is needed refuses as well");
remove(world.parent.as_path());
}