"""TDD tests for Phase 3 — Full strategy × history × granularity matrix. Issue #86 Phase 3 deliverables: SM_01–04: convergent (untouched file) under each strategy → no conflict SM_05–08: convergent (convergent edit) under each strategy → no conflict SM_09–12: file divergence: recursive/overlay/snapshot/replay SM_13–15: add/add collision: recursive/overlay/snapshot SM_16–18: delete/modify: recursive/overlay/snapshot SM_19–21: history mode → correct commit graph shape SM_22–24: PHANTOM regression guard under all 4 strategies """ from __future__ import annotations import datetime import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.types import blob_id, fake_id from muse.core.object_store import write_object from muse.core.paths import heads_dir, muse_dir, ref_path runner = CliRunner() cli = None # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _env(root: pathlib.Path) -> dict: return {"MUSE_REPO_ROOT": str(root)} def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() repo_id = fake_id("repo") (dot_muse / "repo.json").write_text(json.dumps({ "repo_id": repo_id, "domain": "code", "default_branch": "main", "created_at": "2025-01-01T00:00:00+00:00", }), encoding="utf-8") (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "snapshots").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "objects").mkdir() return tmp_path, repo_id def _write_obj(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid def _make_commit( root: pathlib.Path, repo_id: str, branch: str = "main", message: str = "test", manifest: dict | None = None, parent_id: str | None = None, ) -> str: from muse.core.commits import CommitRecord, write_commit from muse.core.snapshots import SnapshotRecord, write_snapshot from muse.core.ids import hash_snapshot, hash_commit ref_file = ref_path(root, branch) if parent_id is None: parent_id = ref_file.read_text().strip() if ref_file.exists() else None m = manifest or {} snap_id = hash_snapshot(m) committed_at = datetime.datetime.now(datetime.timezone.utc) commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [], snapshot_id=snap_id, message=message, committed_at_iso=committed_at.isoformat(), ) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m)) write_commit(root, CommitRecord( commit_id=commit_id, branch=branch, snapshot_id=snap_id, message=message, committed_at=committed_at, parent_commit_id=parent_id, )) ref_file.parent.mkdir(parents=True, exist_ok=True) ref_file.write_text(commit_id, encoding="utf-8") return commit_id def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None: """Set HEAD to branch and write manifest file contents to disk.""" (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") for path, oid in manifest.items(): from muse.core.object_store import read_object content = read_object(root, oid) if content is not None: dest = root / path dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(content) def _merged_snapshot(root: pathlib.Path, branch: str = "main") -> dict: """Read the manifest of the current HEAD commit on *branch*.""" from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot from muse.core.refs import resolve_any_ref commit_id = resolve_any_ref(root, branch) assert commit_id is not None rec = read_commit(root, commit_id) assert rec is not None snap = read_snapshot(root, rec.snapshot_id) assert snap is not None return snap.manifest def _untouched_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]: """Repo where feat only modifies file_x; shared.py is untouched on both sides.""" root, repo_id = _init_repo(tmp_path) shared_id = _write_obj(root, b"shared unchanged on both sides") x_base = _write_obj(root, b"file_x base version") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_base, "shared.py": shared_id}) x_feat = _write_obj(root, b"file_x modified by feat") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat changes x", {"file_x.py": x_feat, "shared.py": shared_id}, parent_id=base_id) _checkout(root, "main", {"file_x.py": x_base, "shared.py": shared_id}) return root, repo_id, x_feat, shared_id def _convergent_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str]: """Repo where both sides independently arrive at the same content for config.py.""" root, repo_id = _init_repo(tmp_path) cfg_v1 = _write_obj(root, b"config = 1") base_id = _make_commit(root, repo_id, "main", "base", {"config.py": cfg_v1}) cfg_v2 = _write_obj(root, b"config = 2 # same on both sides") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat to v2", {"config.py": cfg_v2}, parent_id=base_id) # main also arrives at v2 _make_commit(root, repo_id, "main", "main to v2", {"config.py": cfg_v2}, parent_id=base_id) _checkout(root, "main", {"config.py": cfg_v2}) return root, repo_id, cfg_v2 def _divergent_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]: """Repo where config.py is modified differently on main and feat. Uses valid Python with the same symbol name (`config`) set to different values so the CodePlugin detects a symbol-level conflict and does NOT auto-merge via the independence path. """ root, repo_id = _init_repo(tmp_path) # Valid Python: single assignment so CodePlugin sees symbol `config` cfg_base = _write_obj(root, b"config = 1\n") base_id = _make_commit(root, repo_id, "main", "base", {"config.py": cfg_base}) cfg_ours = _write_obj(root, b"config = 2\n") cfg_theirs = _write_obj(root, b"config = 3\n") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat changes config", {"config.py": cfg_theirs}, parent_id=base_id) _make_commit(root, repo_id, "main", "main changes config", {"config.py": cfg_ours}, parent_id=base_id) _checkout(root, "main", {"config.py": cfg_ours}) return root, repo_id, cfg_ours, cfg_theirs def _addadd_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]: """Repo where both sides add new.py at same path with different content. Uses valid Python with a function of the same name so the CodePlugin can detect a symbol-level conflict rather than auto-merging via independence. """ root, repo_id = _init_repo(tmp_path) base_file = _write_obj(root, b"x = 1\n") base_id = _make_commit(root, repo_id, "main", "base", {"existing.py": base_file}) # Both branches add the same path — different function bodies ensure conflict new_ours = _write_obj(root, b"def helper():\n return 'ours'\n") new_theirs = _write_obj(root, b"def helper():\n return 'theirs'\n") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat adds new.py", {"existing.py": base_file, "new.py": new_theirs}, parent_id=base_id) _make_commit(root, repo_id, "main", "main adds new.py", {"existing.py": base_file, "new.py": new_ours}, parent_id=base_id) _checkout(root, "main", {"existing.py": base_file, "new.py": new_ours}) return root, repo_id, new_ours, new_theirs def _deletemodify_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str]: """Repo where main deletes target.py and feat modifies it.""" root, repo_id = _init_repo(tmp_path) # Valid Python ensures the CodePlugin doesn't accidentally auto-merge target_base = _write_obj(root, b"value = 0\n") base_id = _make_commit(root, repo_id, "main", "base", {"target.py": target_base}) target_modified = _write_obj(root, b"value = 42\n") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat modifies target", {"target.py": target_modified}, parent_id=base_id) # main deletes target.py _make_commit(root, repo_id, "main", "main deletes target", {}, parent_id=base_id) _checkout(root, "main", {}) return root, repo_id, target_modified def _clean_twobranch_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: """Clean merge: main changes file_a, feat changes file_b — no conflicts.""" root, repo_id = _init_repo(tmp_path) a_id = _write_obj(root, b"file_a base") b_id = _write_obj(root, b"file_b base") base_id = _make_commit(root, repo_id, "main", "base", {"file_a.py": a_id, "file_b.py": b_id}) a_v2 = _write_obj(root, b"file_a v2 main only") b_v2 = _write_obj(root, b"file_b v2 feat only") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat changes b", {"file_a.py": a_id, "file_b.py": b_v2}, parent_id=base_id) _make_commit(root, repo_id, "main", "main changes a", {"file_a.py": a_v2, "file_b.py": b_id}, parent_id=base_id) _checkout(root, "main", {"file_a.py": a_v2, "file_b.py": b_id}) return root, repo_id # --------------------------------------------------------------------------- # Group 1 — Convergent cases under every strategy (SM_01–SM_08) # All must produce conflicts == [] # --------------------------------------------------------------------------- class TestConvergentUntouched: """SM_01–04: untouched file under each strategy must not produce a conflict.""" def test_SM_01_recursive_untouched_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_01 — recursive + untouched file → no conflict.""" root, _, _, shared_id = _untouched_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), "SM_01: shared.py must not conflict" assert data.get("exit_code") == 0 def test_SM_02_overlay_untouched_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_02 — overlay + untouched file → no conflict.""" root, _, _, shared_id = _untouched_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "overlay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), "SM_02: shared.py must not conflict" assert data.get("exit_code") == 0 def test_SM_03_snapshot_untouched_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_03 — snapshot + untouched file → no conflict.""" root, _, _, shared_id = _untouched_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "snapshot", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), "SM_03: shared.py must not conflict" assert data.get("exit_code") == 0 def test_SM_04_replay_untouched_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_04 — replay + untouched file → no conflict.""" root, _, _, shared_id = _untouched_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "replay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), "SM_04: shared.py must not conflict" assert data.get("exit_code") == 0 class TestConvergentEdit: """SM_05–08: convergent edit under each strategy → no conflict, convergent content in snapshot.""" def test_SM_05_recursive_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_05 — recursive + convergent edit → no conflict, convergent content in merged snapshot.""" root, _, cfg_v2 = _convergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_05: convergent edit must not conflict" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_v2, "SM_05: merged snapshot must have convergent content" def test_SM_06_overlay_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_06 — overlay + convergent edit → no conflict, convergent content in merged snapshot.""" root, _, cfg_v2 = _convergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "overlay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_06: convergent edit must not conflict" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_v2, "SM_06: merged snapshot must have convergent content" def test_SM_07_snapshot_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_07 — snapshot + convergent edit → no conflict, convergent content in merged snapshot.""" root, _, cfg_v2 = _convergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "snapshot", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_07: convergent edit must not conflict" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_v2, "SM_07: merged snapshot must have convergent content" def test_SM_08_replay_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """SM_08 — replay + convergent edit → no conflict, convergent content in merged snapshot.""" root, _, cfg_v2 = _convergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "replay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_08: convergent edit must not conflict" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_v2, "SM_08: merged snapshot must have convergent content" # --------------------------------------------------------------------------- # Group 2 — Divergent cases: conflict surfacing vs auto-resolution (SM_09–SM_18) # --------------------------------------------------------------------------- class TestFileDivergence: """SM_09–12: file divergence under each strategy.""" def test_SM_09_recursive_file_divergence_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_09 — recursive + file divergence → conflicts non-empty.""" root, _, _, _ = _divergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_09: recursive must surface file divergence conflict" def test_SM_10_overlay_file_divergence_no_conflict_theirs_wins(self, tmp_path: pathlib.Path) -> None: """SM_10 — overlay + file divergence → conflicts == [], theirs wins in merged snapshot.""" root, _, cfg_ours, cfg_theirs = _divergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "overlay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_10: overlay must auto-resolve file divergence" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_theirs, "SM_10: overlay must keep theirs (feat) version" def test_SM_11_snapshot_file_divergence_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_11 — snapshot + file divergence → conflicts non-empty.""" root, _, _, _ = _divergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "snapshot", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_11: snapshot must surface file divergence conflict" def test_SM_12_replay_file_divergence_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_12 — replay + file divergence → conflicts non-empty.""" root, _, _, _ = _divergent_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "replay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_12: replay must surface file divergence conflict" class TestAddAddCollision: """SM_13–15: add/add collision under recursive, overlay, snapshot.""" def test_SM_13_recursive_addadd_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_13 — recursive + add/add collision → conflicts non-empty.""" root, _, _, _ = _addadd_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_13: recursive must surface add/add collision" def test_SM_14_overlay_addadd_no_conflict_theirs_wins(self, tmp_path: pathlib.Path) -> None: """SM_14 — overlay + add/add collision → conflicts == [], theirs content wins.""" root, _, new_ours, new_theirs = _addadd_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "overlay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_14: overlay must auto-resolve add/add collision" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert snap.get("new.py") == new_theirs, "SM_14: overlay must keep theirs (feat) version of new.py" def test_SM_15_snapshot_addadd_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_15 — snapshot + add/add collision → conflicts non-empty.""" root, _, _, _ = _addadd_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "snapshot", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_15: snapshot must surface add/add collision" class TestDeleteModify: """SM_16–18: delete/modify under recursive, overlay, snapshot.""" def test_SM_16_recursive_deletemodify_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_16 — recursive + delete/modify → conflicts non-empty.""" root, _, _ = _deletemodify_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_16: recursive must surface delete/modify conflict" def test_SM_17_overlay_deletemodify_no_conflict_modified_survives(self, tmp_path: pathlib.Path) -> None: """SM_17 — overlay + delete/modify → conflicts == [], modified version survives.""" root, _, target_modified = _deletemodify_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "overlay", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], "SM_17: overlay must auto-resolve delete/modify" assert data.get("exit_code") == 0 snap = _merged_snapshot(root, "main") assert "target.py" in snap, "SM_17: target.py must survive (theirs/feat modified it)" assert snap["target.py"] == target_modified, "SM_17: theirs modified version must win" def test_SM_18_snapshot_deletemodify_conflicts(self, tmp_path: pathlib.Path) -> None: """SM_18 — snapshot + delete/modify → conflicts non-empty.""" root, _, _ = _deletemodify_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--strategy", "snapshot", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert len(data.get("conflicts", [])) > 0, "SM_18: snapshot must surface delete/modify conflict" # --------------------------------------------------------------------------- # Group 3 — History mode produces correct commit graph shape (SM_19–SM_21) # --------------------------------------------------------------------------- class TestHistoryMode: """SM_19–21: --history flag controls commit graph structure.""" def test_SM_19_history_merge_two_parent_commit(self, tmp_path: pathlib.Path) -> None: """SM_19 — --history merge → commit has two parents (parent2_commit_id set).""" root, _ = _clean_twobranch_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--history", "merge", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("exit_code") == 0, "SM_19: clean merge must succeed" commit_id = data.get("commit_id") assert commit_id is not None from muse.core.commits import read_commit rec = read_commit(root, commit_id) assert rec is not None assert rec.parent2_commit_id is not None, "SM_19: --history merge must produce two-parent commit" def test_SM_20_history_squash_single_parent_commit(self, tmp_path: pathlib.Path) -> None: """SM_20 — --history squash → commit has one parent (parent2_commit_id is None).""" root, _ = _clean_twobranch_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--history", "squash", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("exit_code") == 0, "SM_20: clean squash merge must succeed" commit_id = data.get("commit_id") assert commit_id is not None from muse.core.commits import read_commit rec = read_commit(root, commit_id) assert rec is not None assert rec.parent2_commit_id is None, "SM_20: --history squash must produce single-parent commit" def test_SM_21_history_rebase_single_parent_commit(self, tmp_path: pathlib.Path) -> None: """SM_21 — --history rebase → commits are linear; no merge commit created. Full commit-by-commit replay is Phase 4 scope. For now, rebase produces a single-parent commit (same graph shape as squash). """ root, _ = _clean_twobranch_repo(tmp_path) result = runner.invoke(cli, ["merge", "feat", "--history", "rebase", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("exit_code") == 0, "SM_21: clean rebase merge must succeed" commit_id = data.get("commit_id") assert commit_id is not None from muse.core.commits import read_commit rec = read_commit(root, commit_id) assert rec is not None assert rec.parent2_commit_id is None, "SM_21: --history rebase must produce linear (single-parent) commit" # --------------------------------------------------------------------------- # Group 4 — PHANTOM regressions under all 4 strategies (SM_22–SM_24) # --------------------------------------------------------------------------- class TestPhantomRegressions: """SM_22–24: PHANTOM_01/02/05 scenarios must pass under every strategy.""" def test_SM_22_phantom01_untouched_all_strategies(self, tmp_path: pathlib.Path) -> None: """SM_22 — PHANTOM_01 (untouched file) passes under recursive, overlay, snapshot, replay.""" for strategy in ("recursive", "overlay", "snapshot", "replay"): sub = tmp_path / strategy sub.mkdir() root, _, _, shared_id = _untouched_repo(sub) result = runner.invoke( cli, ["merge", "feat", "--strategy", strategy, "--json"], env=_env(root), catch_exceptions=False, ) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), ( f"SM_22: untouched shared.py must not conflict under --strategy {strategy}" ) assert data.get("exit_code") == 0, ( f"SM_22: merge must succeed under --strategy {strategy}" ) def test_SM_23_phantom02_convergent_edit_all_strategies(self, tmp_path: pathlib.Path) -> None: """SM_23 — PHANTOM_02 (convergent edit) passes under all 4 strategies; merged snapshot has convergent content.""" for strategy in ("recursive", "overlay", "snapshot", "replay"): sub = tmp_path / strategy sub.mkdir() root, _, cfg_v2 = _convergent_repo(sub) result = runner.invoke( cli, ["merge", "feat", "--strategy", strategy, "--json"], env=_env(root), catch_exceptions=False, ) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], ( f"SM_23: convergent edit must not conflict under --strategy {strategy}" ) assert data.get("exit_code") == 0, ( f"SM_23: merge must succeed under --strategy {strategy}" ) snap = _merged_snapshot(root, "main") assert snap.get("config.py") == cfg_v2, ( f"SM_23: convergent content must be in merged snapshot under --strategy {strategy}" ) def test_SM_24_phantom05_both_changes_in_merged_snapshot_all_strategies(self, tmp_path: pathlib.Path) -> None: """SM_24 — PHANTOM_05 (clean merge has changes from both branches) under all 4 strategies.""" for strategy in ("recursive", "overlay", "snapshot", "replay"): sub = tmp_path / strategy sub.mkdir() root, repo_id = _init_repo(sub) a_id = _write_obj(root, b"file_a base") b_id = _write_obj(root, b"file_b base") base_id = _make_commit(root, repo_id, "main", "base", {"file_a.py": a_id, "file_b.py": b_id}) a_v2 = _write_obj(root, b"file_a v2 main only changes this") b_v2 = _write_obj(root, b"file_b v2 feat only changes this") (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") _make_commit(root, repo_id, "feat", "feat changes b", {"file_a.py": a_id, "file_b.py": b_v2}, parent_id=base_id) _make_commit(root, repo_id, "main", "main changes a", {"file_a.py": a_v2, "file_b.py": b_id}, parent_id=base_id) _checkout(root, "main", {"file_a.py": a_v2, "file_b.py": b_id}) result = runner.invoke( cli, ["merge", "feat", "--strategy", strategy, "--json"], env=_env(root), catch_exceptions=False, ) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], ( f"SM_24: clean merge must not conflict under --strategy {strategy}" ) assert data.get("exit_code") == 0, ( f"SM_24: clean merge must succeed under --strategy {strategy}" ) snap = _merged_snapshot(root, "main") assert snap.get("file_a.py") == a_v2, ( f"SM_24: main's file_a change must survive under --strategy {strategy}" ) assert snap.get("file_b.py") == b_v2, ( f"SM_24: feat's file_b change must survive under --strategy {strategy}" )