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());
}