use std::path::Path;
use std::process::Command;
use assert_cmd::prelude::*;
use tempfile::TempDir;
fn mnem(repo: &Path, args: &[&str]) -> Command {
let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
cmd.current_dir(repo);
cmd.arg("-R").arg(repo);
for a in args {
cmd.arg(a);
}
cmd
}
fn init(dir: &Path) {
mnem(dir, &["init", dir.to_str().unwrap()])
.assert()
.success();
}
fn add_node(dir: &Path, summary: &str) {
mnem(dir, &["add", "node", "--summary", summary, "--no-embed"])
.assert()
.success();
}
fn head_cid(dir: &Path) -> String {
let out = mnem(dir, &["status"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix("commit ") {
let cid = rest.trim().to_string();
if !cid.is_empty() && !cid.starts_with('<') {
return cid;
}
}
}
panic!("could not extract HEAD CID from `mnem status` output:\n{stdout}");
}
fn setup_diverged_branches(dir: &Path) -> (String, String) {
add_node(dir, "shared-base");
mnem(dir, &["branch", "create", "feature"])
.assert()
.success();
add_node(dir, "main-only");
let main_tip = head_cid(dir);
mnem(dir, &["switch", "feature"]).assert().success();
add_node(dir, "feature-only");
let feature_tip = head_cid(dir);
mnem(dir, &["switch", "main"]).assert().success();
assert_ne!(main_tip, feature_tip, "branches must have diverged");
(main_tip, feature_tip)
}
#[test]
fn merge_missing_branch_errors_actionably() {
let dir = TempDir::new().unwrap();
mnem(dir.path(), &["init", dir.path().to_str().unwrap()])
.assert()
.success();
let out = mnem(dir.path(), &["merge"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("missing <branch>") || stderr.contains("required"),
"missing-branch diagnostic should mention the missing argument: {stderr}"
);
}
#[test]
fn merge_already_up_to_date_when_same_commit() {
let dir = TempDir::new().unwrap();
mnem(dir.path(), &["init", dir.path().to_str().unwrap()])
.assert()
.success();
mnem(
dir.path(),
&["add", "node", "--summary", "seed", "--prop", "k=1"],
)
.assert()
.success();
mnem(dir.path(), &["branch", "create", "feat"])
.assert()
.success();
let out = mnem(dir.path(), &["merge", "feat"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("already up to date") || stdout.contains("fast-forward"),
"expected up-to-date or FF, got: {stdout}"
);
}
#[test]
fn merge_abort_without_in_progress_errors() {
let dir = TempDir::new().unwrap();
mnem(dir.path(), &["init", dir.path().to_str().unwrap()])
.assert()
.success();
let out = mnem(dir.path(), &["merge", "--abort"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("no merge in progress"),
"abort without in-progress should complain: {stderr}"
);
}
#[test]
fn merge_help_lists_strategy_and_flags() {
let out = Command::cargo_bin("mnem")
.unwrap()
.args(["merge", "--help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
for needle in [
"--strategy",
"--dry-run",
"--continue",
"--abort",
"ours",
"theirs",
"manual",
] {
assert!(
stdout.contains(needle),
"merge --help must surface `{needle}`, got: {stdout}"
);
}
}
#[test]
fn merge_clean_diverged_branches_succeeds() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (_main_tip, _feature_tip) = setup_diverged_branches(p);
let out = mnem(p, &["merge", "feature"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("fast-forward")
|| stdout.contains("merge:")
|| stdout.contains("already up to date"),
"merge of non-overlapping branches should succeed with a merge/ff message, got: {stdout}"
);
let merge_head = p.join(".mnem").join("MERGE_HEAD");
assert!(
!merge_head.exists(),
"MERGE_HEAD must not exist after a clean merge"
);
}
#[test]
fn merge_dry_run_persists_no_state() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (main_tip, _feature_tip) = setup_diverged_branches(p);
let head_before = head_cid(p);
assert_eq!(
head_before, main_tip,
"we should be back on main after setup"
);
let out = mnem(p, &["merge", "--dry-run", "feature"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("[dry-run]"),
"--dry-run output should contain '[dry-run]' prefix, got: {stdout}"
);
let mnem_dir = p.join(".mnem");
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"--dry-run must not write MERGE_HEAD"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"--dry-run must not write ORIG_HEAD"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"--dry-run must not write MERGE_CONFLICTS.json"
);
let head_after = head_cid(p);
assert_eq!(head_before, head_after, "--dry-run must not advance HEAD");
}
#[test]
fn merge_strategy_ours_accepted() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
setup_diverged_branches(p);
let out = mnem(p, &["merge", "--strategy=ours", "feature"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("fast-forward")
|| stdout.contains("merge:")
|| stdout.contains("already up to date"),
"--strategy=ours merge should succeed, got: {stdout}"
);
}
#[test]
fn merge_strategy_theirs_accepted() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
setup_diverged_branches(p);
let out = mnem(p, &["merge", "--strategy=theirs", "feature"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("fast-forward")
|| stdout.contains("merge:")
|| stdout.contains("already up to date"),
"--strategy=theirs merge should succeed, got: {stdout}"
);
}
#[test]
fn merge_abort_with_in_progress_removes_sentinels() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "base-commit");
let cid = head_cid(p);
let mnem_dir = p.join(".mnem");
std::fs::write(mnem_dir.join("MERGE_HEAD"), &cid).expect("could not write MERGE_HEAD");
std::fs::write(mnem_dir.join("ORIG_HEAD"), &cid).expect("could not write ORIG_HEAD");
std::fs::write(
mnem_dir.join("MERGE_CONFLICTS.json"),
r#"{"schema":"mnem.v1.merge_conflicts","left_head":"fake","right_head":"fake","conflicts":[]}"#,
)
.expect("could not write MERGE_CONFLICTS.json");
assert!(
mnem_dir.join("MERGE_HEAD").exists(),
"sanity: MERGE_HEAD must exist before abort"
);
let out = mnem(p, &["merge", "--abort"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("aborted") || stdout.contains("abort"),
"--abort should confirm cancellation, got: {stdout}"
);
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"--abort must remove MERGE_HEAD"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"--abort must remove ORIG_HEAD"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"--abort must remove MERGE_CONFLICTS.json"
);
}
#[test]
fn merge_continue_without_in_progress_errors() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let out = mnem(p, &["merge", "--continue"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("no merge in progress"),
"--continue without MERGE_HEAD must say 'no merge in progress', got: {stderr}"
);
}
#[test]
fn merge_continue_with_unresolved_conflicts_errors() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "anchor");
let cid = head_cid(p);
let mnem_dir = p.join(".mnem");
std::fs::write(mnem_dir.join("MERGE_HEAD"), &cid).expect("write MERGE_HEAD");
std::fs::write(mnem_dir.join("ORIG_HEAD"), &cid).expect("write ORIG_HEAD");
let conflicts_json = format!(
r#"{{
"schema": "mnem.v1.merge_conflicts",
"left_head": "{cid}",
"right_head": "{cid}",
"conflicts": [
{{
"node_id": "00000000-0000-0000-0000-000000000001",
"category": "node_cid_divergence",
"left": {{"cid": "bafy000left"}},
"right": {{"cid": "bafy000right"}}
}}
]
}}"#
);
std::fs::write(mnem_dir.join("MERGE_CONFLICTS.json"), &conflicts_json)
.expect("write MERGE_CONFLICTS.json");
let out = mnem(p, &["merge", "--continue"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("resolution")
|| stderr.contains("unresolved")
|| stderr.contains("conflict"),
"--continue with unresolved conflicts must mention resolution/conflict, got: {stderr}"
);
}
fn branch_tip(dir: &Path, branch: &str) -> String {
let out = mnem(dir, &["ref", "list"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
let prefix = format!("refs/heads/{branch}");
for line in stdout.lines() {
let stripped = line.trim();
if stripped.starts_with(&prefix) {
if let Some(arrow_pos) = stripped.find("->") {
let cid = stripped[arrow_pos + 2..].trim().to_string();
if !cid.is_empty() {
return cid;
}
}
}
}
panic!("could not find ref for branch '{branch}' in `mnem ref list` output:\n{stdout}");
}
const NODE_CONFLICT_ID: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
fn setup_conflicting_branches(dir: &Path) -> (String, String) {
add_node(dir, "shared-base");
mnem(dir, &["branch", "create", "feature"])
.assert()
.success();
mnem(
dir,
&[
"add",
"node",
"--id",
NODE_CONFLICT_ID,
"--summary",
"conflict-node-on-main",
"--no-embed",
],
)
.assert()
.success();
let main_tip = branch_tip(dir, "main");
mnem(dir, &["switch", "feature"]).assert().success();
mnem(
dir,
&[
"add",
"node",
"--id",
NODE_CONFLICT_ID,
"--summary",
"conflict-node-on-feature",
"--no-embed",
],
)
.assert()
.success();
let feature_tip = branch_tip(dir, "feature");
mnem(dir, &["switch", "main"]).assert().success();
assert_ne!(main_tip, feature_tip, "branches must have diverged");
(main_tip, feature_tip)
}
fn resolve_all_conflicts(dir: &Path, resolution: &str) {
let mc_path = dir.join(".mnem").join("MERGE_CONFLICTS.json");
let raw = std::fs::read_to_string(&mc_path)
.expect("MERGE_CONFLICTS.json must exist before resolving");
let mut data: serde_json::Value =
serde_json::from_str(&raw).expect("MERGE_CONFLICTS.json must be valid JSON");
if let Some(conflicts) = data.get_mut("conflicts").and_then(|v| v.as_array_mut()) {
for entry in conflicts.iter_mut() {
if let Some(obj) = entry.as_object_mut() {
obj.insert(
"resolution".to_string(),
serde_json::Value::String(resolution.to_string()),
);
}
}
}
std::fs::write(
&mc_path,
serde_json::to_string_pretty(&data).expect("re-serialise"),
)
.expect("write resolved MERGE_CONFLICTS.json");
}
#[test]
fn merge_continue_resolves_to_completion() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (main_tip_before, _feature_tip) = setup_conflicting_branches(p);
let mnem_dir = p.join(".mnem");
let out = mnem(p, &["merge", "feature"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("conflict"),
"merge of conflicting branches must report conflicts, got: {stdout}"
);
assert!(
mnem_dir.join("MERGE_HEAD").exists(),
"MERGE_HEAD must exist after a conflicting merge"
);
assert!(
mnem_dir.join("ORIG_HEAD").exists(),
"ORIG_HEAD must exist after a conflicting merge"
);
assert!(
mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"MERGE_CONFLICTS.json must exist after a conflicting merge"
);
resolve_all_conflicts(p, "ours");
let out = mnem(p, &["merge", "--continue"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("merge continued") || stdout.contains("advanced") || stdout.contains("->"),
"--continue must report that the merge was finalised, got: {stdout}"
);
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"MERGE_HEAD must be removed after --continue"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"ORIG_HEAD must be removed after --continue"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"MERGE_CONFLICTS.json must be removed after --continue"
);
let main_tip_after = branch_tip(p, "main");
assert_ne!(
main_tip_before, main_tip_after,
"refs/heads/main must advance after --continue"
);
}
#[test]
fn merge_strategy_ours_picks_left_on_conflict() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (main_tip_before, _feature_tip) = setup_conflicting_branches(p);
let mnem_dir = p.join(".mnem");
let out = mnem(p, &["merge", "--strategy=ours", "feature"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("merge:") || stdout.contains("advanced") || stdout.contains("->"),
"--strategy=ours on a conflicting merge must produce a merge commit, got: {stdout}"
);
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"--strategy=ours must not leave MERGE_HEAD"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"--strategy=ours must not leave ORIG_HEAD"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"--strategy=ours must not leave MERGE_CONFLICTS.json"
);
let main_tip_after = branch_tip(p, "main");
assert_ne!(
main_tip_before, main_tip_after,
"refs/heads/main must advance after --strategy=ours merge"
);
let out = mnem(p, &["get", NODE_CONFLICT_ID]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("conflict-node-on-main"),
"--strategy=ours must pick the left (main) side on conflict, got: {stdout}"
);
assert!(
!stdout.contains("conflict-node-on-feature"),
"--strategy=ours must NOT pick the right (feature) side on conflict, got: {stdout}"
);
}
#[test]
fn merge_strategy_theirs_picks_right_on_conflict() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (main_tip_before, _feature_tip) = setup_conflicting_branches(p);
let mnem_dir = p.join(".mnem");
let out = mnem(p, &["merge", "--strategy=theirs", "feature"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("merge:") || stdout.contains("advanced") || stdout.contains("->"),
"--strategy=theirs on a conflicting merge must produce a merge commit, got: {stdout}"
);
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"--strategy=theirs must not leave MERGE_HEAD"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"--strategy=theirs must not leave ORIG_HEAD"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"--strategy=theirs must not leave MERGE_CONFLICTS.json"
);
let main_tip_after = branch_tip(p, "main");
assert_ne!(
main_tip_before, main_tip_after,
"refs/heads/main must advance after --strategy=theirs merge"
);
let out = mnem(p, &["get", NODE_CONFLICT_ID]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("conflict-node-on-feature"),
"--strategy=theirs must pick the right (feature) side on conflict, got: {stdout}"
);
assert!(
!stdout.contains("conflict-node-on-main"),
"--strategy=theirs must NOT pick the left (main) side on conflict, got: {stdout}"
);
}
#[test]
fn merge_continue_missing_conflicts_json_errors() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "anchor");
let cid = head_cid(p);
let mnem_dir = p.join(".mnem");
std::fs::write(mnem_dir.join("MERGE_HEAD"), &cid).expect("write MERGE_HEAD");
std::fs::write(mnem_dir.join("ORIG_HEAD"), &cid).expect("write ORIG_HEAD");
let out = mnem(p, &["merge", "--continue"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("MERGE_CONFLICTS")
|| stderr.contains("not found")
|| stderr.contains("conflict"),
"--continue with missing MERGE_CONFLICTS.json must complain about it, got: {stderr}"
);
}
#[test]
fn merge_continue_mixed_resolutions_errors() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "anchor");
let cid = head_cid(p);
let mnem_dir = p.join(".mnem");
std::fs::write(mnem_dir.join("MERGE_HEAD"), &cid).expect("write MERGE_HEAD");
std::fs::write(mnem_dir.join("ORIG_HEAD"), &cid).expect("write ORIG_HEAD");
let conflicts_json = format!(
r#"{{
"schema": "mnem.v1.merge_conflicts",
"left_head": "{cid}",
"right_head": "{cid}",
"conflicts": [
{{
"node_id": "00000000-0000-0000-0000-000000000001",
"category": "node_cid_divergence",
"left": {{"cid": "bafy000left1"}},
"right": {{"cid": "bafy000right1"}},
"resolution": "ours"
}},
{{
"node_id": "00000000-0000-0000-0000-000000000002",
"category": "node_cid_divergence",
"left": {{"cid": "bafy000left2"}},
"right": {{"cid": "bafy000right2"}},
"resolution": "theirs"
}}
]
}}"#
);
std::fs::write(mnem_dir.join("MERGE_CONFLICTS.json"), &conflicts_json)
.expect("write MERGE_CONFLICTS.json");
let out = mnem(p, &["merge", "--continue"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("mixed") || stderr.contains("ours") || stderr.contains("theirs"),
"--continue with mixed resolutions must mention the mixed-picks problem, got: {stderr}"
);
}
#[test]
fn merge_continue_empty_conflicts_list_succeeds() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let (main_tip_before, _feature_tip) = setup_conflicting_branches(p);
let mnem_dir = p.join(".mnem");
let out = mnem(p, &["merge", "feature"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("conflict"),
"setup: merge should have produced conflicts, got: {stdout}"
);
let mc_path = mnem_dir.join("MERGE_CONFLICTS.json");
let raw = std::fs::read_to_string(&mc_path).expect("MERGE_CONFLICTS.json must exist");
let mut data: serde_json::Value =
serde_json::from_str(&raw).expect("MERGE_CONFLICTS.json must be valid JSON");
*data.get_mut("conflicts").unwrap() = serde_json::Value::Array(vec![]);
std::fs::write(&mc_path, serde_json::to_string_pretty(&data).unwrap())
.expect("overwrite MERGE_CONFLICTS.json");
let out = mnem(p, &["merge", "--continue"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("merge continued") || stdout.contains("advanced") || stdout.contains("->"),
"--continue with empty conflicts must finalise the merge, got: {stdout}"
);
assert!(
!mnem_dir.join("MERGE_HEAD").exists(),
"MERGE_HEAD must be removed after --continue"
);
assert!(
!mnem_dir.join("ORIG_HEAD").exists(),
"ORIG_HEAD must be removed after --continue"
);
assert!(
!mnem_dir.join("MERGE_CONFLICTS.json").exists(),
"MERGE_CONFLICTS.json must be removed after --continue"
);
let main_tip_after = branch_tip(p, "main");
assert_ne!(
main_tip_before, main_tip_after,
"refs/heads/main must advance after --continue with empty conflicts list"
);
}