use objects::{
object::{ChangeId, ThreadName},
store::ObjectStore,
};
use repo::{MergeState, MergeStateManager, Repository};
use tempfile::TempDir;
#[test]
fn test_detect_divergent_history() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("common.txt"), "common base").unwrap();
let base_state = repo.snapshot(Some("Base state".to_string()), None).unwrap();
repo.refs()
.set_thread(&ThreadName::new("feature"), &base_state.change_id)
.unwrap();
std::fs::write(temp.path().join("feature.txt"), "feature work").unwrap();
let feature_state = repo
.snapshot(Some("Feature work".to_string()), None)
.unwrap();
repo.goto(&base_state.change_id).unwrap();
std::fs::write(temp.path().join("main.txt"), "main work").unwrap();
let main_state = repo.snapshot(Some("Main work".to_string()), None).unwrap();
assert_ne!(feature_state.change_id, main_state.change_id);
assert_eq!(feature_state.parents, vec![base_state.change_id]);
assert_eq!(main_state.parents, vec![base_state.change_id]);
}
#[test]
fn test_find_common_ancestor() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("file.txt"), "A").unwrap();
let _state_a = repo.snapshot(Some("A".to_string()), None).unwrap();
std::fs::write(temp.path().join("file.txt"), "B").unwrap();
let state_b = repo.snapshot(Some("B".to_string()), None).unwrap();
std::fs::write(temp.path().join("file.txt"), "C").unwrap();
let state_c = repo.snapshot(Some("C".to_string()), None).unwrap();
repo.goto(&state_b.change_id).unwrap();
std::fs::write(temp.path().join("file.txt"), "D").unwrap();
let state_d = repo.snapshot(Some("D".to_string()), None).unwrap();
assert_eq!(state_c.parents, vec![state_b.change_id]);
assert_eq!(state_d.parents, vec![state_b.change_id]);
}
#[test]
fn test_three_way_merge_base() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("base.txt"), "base content").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::write(temp.path().join("file1.txt"), "branch1").unwrap();
let branch1 = repo.snapshot(Some("Branch 1".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("file2.txt"), "branch2").unwrap();
let branch2 = repo.snapshot(Some("Branch 2".to_string()), None).unwrap();
assert_eq!(branch1.parents[0], base.change_id);
assert_eq!(branch2.parents[0], base.change_id);
}
#[test]
fn test_detect_conflicting_modifications() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("conflict.txt"), "base").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::write(temp.path().join("conflict.txt"), "branch1").unwrap();
repo.snapshot(Some("Branch 1".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("conflict.txt"), "branch2").unwrap();
repo.snapshot(Some("Branch 2".to_string()), None).unwrap();
}
#[test]
fn test_non_conflicting_changes() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("base.txt"), "base").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::write(temp.path().join("file1.txt"), "branch1").unwrap();
let branch1 = repo.snapshot(Some("Branch 1".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("file2.txt"), "branch2").unwrap();
let branch2 = repo.snapshot(Some("Branch 2".to_string()), None).unwrap();
assert!(
repo.store()
.get_state(&branch1.change_id)
.unwrap()
.is_some()
);
assert!(
repo.store()
.get_state(&branch2.change_id)
.unwrap()
.is_some()
);
}
#[test]
fn test_fast_forward_detection() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("file.txt"), "A").unwrap();
let state_a = repo.snapshot(Some("A".to_string()), None).unwrap();
std::fs::write(temp.path().join("file.txt"), "B").unwrap();
let state_b = repo.snapshot(Some("B".to_string()), None).unwrap();
std::fs::write(temp.path().join("file.txt"), "C").unwrap();
let state_c = repo.snapshot(Some("C".to_string()), None).unwrap();
assert_eq!(state_b.parents, vec![state_a.change_id]);
assert_eq!(state_c.parents, vec![state_b.change_id]);
}
#[test]
fn test_modify_vs_delete_conflict() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::write(temp.path().join("file.txt"), "modified").unwrap();
repo.snapshot(Some("Modified".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::remove_file(temp.path().join("file.txt")).unwrap();
repo.snapshot(Some("Deleted".to_string()), None).unwrap();
}
#[test]
fn test_rename_detection_in_merge() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("oldname.txt"), "content").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::remove_file(temp.path().join("oldname.txt")).unwrap();
std::fs::write(temp.path().join("newname.txt"), "content").unwrap();
let _branch1 = repo.snapshot(Some("Renamed".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("oldname.txt"), "modified content").unwrap();
let _branch2 = repo
.snapshot(Some("Modified original".to_string()), None)
.unwrap();
}
#[test]
fn test_octopus_merge_structure() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("base.txt"), "base").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
let mut parent_ids = vec![base.change_id];
for i in 1..=3 {
repo.goto(&base.change_id).unwrap();
std::fs::write(
temp.path().join(format!("branch{}.txt", i)),
format!("branch {}", i),
)
.unwrap();
let state = repo.snapshot(Some(format!("Branch {}", i)), None).unwrap();
parent_ids.push(state.change_id);
}
assert!(parent_ids.len() > 2);
}
#[test]
fn test_binary_file_merge() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("image.bin"), vec![0u8, 1, 2, 3, 4]).unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::write(temp.path().join("image.bin"), vec![5u8, 6, 7, 8, 9]).unwrap();
repo.snapshot(Some("Binary 1".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("image.bin"), vec![10u8, 11, 12, 13, 14]).unwrap();
repo.snapshot(Some("Binary 2".to_string()), None).unwrap();
}
#[test]
fn test_directory_file_conflict() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("item.txt"), "file content").unwrap();
let base = repo.snapshot(Some("Base".to_string()), None).unwrap();
std::fs::remove_file(temp.path().join("item.txt")).unwrap();
std::fs::create_dir(temp.path().join("item.txt")).unwrap();
std::fs::write(temp.path().join("item.txt/subfile.txt"), "subcontent").unwrap();
repo.snapshot(Some("Directory".to_string()), None).unwrap();
repo.goto(&base.change_id).unwrap();
std::fs::write(temp.path().join("item.txt"), "modified file").unwrap();
repo.snapshot(Some("Modified file".to_string()), None)
.unwrap();
}
fn merge_manager(temp: &TempDir) -> (Repository, MergeStateManager) {
let repo = Repository::init_default(temp.path()).unwrap();
let manager = repo.merge_state_manager();
(repo, manager)
}
fn sample_ids() -> (ChangeId, ChangeId, ChangeId) {
(
ChangeId::generate(),
ChangeId::generate(),
ChangeId::generate(),
)
}
#[test]
fn merge_state_start_persists_initial_conflict_set() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, base) = sample_ids();
manager
.start(
ours,
theirs,
Some(base),
vec!["a.txt".into(), "b.txt".into()],
)
.unwrap();
let loaded: MergeState = manager.load().unwrap().unwrap();
assert_eq!(loaded.ours, ours);
assert_eq!(loaded.theirs, theirs);
assert_eq!(loaded.base, Some(base));
assert_eq!(loaded.conflicts.len(), 2);
assert!(loaded.resolved.is_empty());
assert!(manager.is_merge_in_progress());
}
#[test]
fn merge_state_resolve_one_path_at_a_time() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, _) = sample_ids();
manager
.start(ours, theirs, None, vec!["a.txt".into(), "b.txt".into()])
.unwrap();
manager.resolve("a.txt").unwrap();
assert_eq!(manager.unresolved().unwrap(), vec!["b.txt".to_string()]);
manager.resolve("b.txt").unwrap();
assert!(manager.unresolved().unwrap().is_empty());
}
#[test]
fn merge_state_finish_fails_when_anything_unresolved() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, _) = sample_ids();
manager
.start(ours, theirs, None, vec!["a.txt".into()])
.unwrap();
let err = manager.finish().unwrap_err();
assert!(
format!("{err}").contains("Unresolved conflicts"),
"expected Unresolved Conflicts error, got: {err}"
);
assert!(
manager.is_merge_in_progress(),
"merge stays alive when finish fails"
);
}
#[test]
fn merge_state_finish_clears_state_when_everything_resolved() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, _) = sample_ids();
manager
.start(ours, theirs, None, vec!["a.txt".into()])
.unwrap();
manager.resolve("a.txt").unwrap();
let final_state = manager.finish().unwrap();
assert_eq!(final_state.ours, ours);
assert!(manager.load().unwrap().is_none());
}
#[test]
fn merge_state_abort_drops_state_without_requiring_resolution() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, _) = sample_ids();
manager
.start(ours, theirs, None, vec!["a.txt".into(), "b.txt".into()])
.unwrap();
let aborted = manager.abort().unwrap();
assert_eq!(aborted.theirs, theirs);
assert!(manager.load().unwrap().is_none());
}
#[test]
fn merge_state_carry_forward_repoints_ours_without_ending_merge() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, base) = sample_ids();
let new_ours = ChangeId::generate();
manager
.start(
ours,
theirs,
Some(base),
vec!["a.txt".into(), "b.txt".into()],
)
.unwrap();
manager.resolve("a.txt").unwrap();
let after = manager.carry_forward(new_ours).unwrap();
assert_eq!(after.ours, new_ours, "ours repointed");
assert_eq!(after.theirs, theirs, "theirs preserved");
assert_eq!(after.base, Some(base), "base preserved");
assert_eq!(
after.resolved,
vec!["a.txt".to_string()],
"resolved preserved"
);
assert!(manager.is_merge_in_progress(), "merge stays alive");
assert_eq!(manager.unresolved().unwrap(), vec!["b.txt".to_string()]);
}
#[test]
fn merge_state_resolve_all_marks_remaining_paths() {
let temp = TempDir::new().unwrap();
let (_repo, manager) = merge_manager(&temp);
let (ours, theirs, _) = sample_ids();
manager
.start(
ours,
theirs,
None,
vec!["a.txt".into(), "b.txt".into(), "c.txt".into()],
)
.unwrap();
manager.resolve("b.txt").unwrap();
let newly = manager.resolve_all().unwrap();
assert_eq!(newly.len(), 2);
assert!(newly.contains(&"a.txt".to_string()));
assert!(newly.contains(&"c.txt".to_string()));
assert!(manager.unresolved().unwrap().is_empty());
}