"""TDD tests for Phase 1 — MergeEngine extraction and CLI flag parity. Issue #86 Phase 1 deliverables: - MergeEngine class with diff_unit + resolution axes - --strategy choices: recursive, overlay, snapshot, replay, ours, theirs - --on-conflict flag: escalate | ours | theirs - --history flag: merge | squash | rebase - strategy aliases: --strategy ours == --strategy recursive --on-conflict ours All tests must be RED before implementation, GREEN after. """ 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, read_object from muse.core.paths import heads_dir, muse_dir, ref_path runner = CliRunner() # --------------------------------------------------------------------------- # Shared test helpers (mirrors test_phantom_conflicts.py) # --------------------------------------------------------------------------- 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 files to disk.""" (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") for path, oid in manifest.items(): 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) # --------------------------------------------------------------------------- # Group 1 — MergeEngine class contract # --------------------------------------------------------------------------- class TestMergeEngineClass: """MergeEngine must exist as an importable class with the right interface.""" def test_ME_01_importable(self) -> None: """MergeEngine is importable from muse.core.merge_engine.""" from muse.core.merge_engine import MergeEngine # noqa: F401 def test_ME_02_valid_construction(self) -> None: """MergeEngine accepts valid diff_unit and resolution values.""" from muse.core.merge_engine import MergeEngine engine = MergeEngine(diff_unit="three_way", resolution="escalate") assert engine.diff_unit == "three_way" assert engine.resolution == "escalate" def test_ME_03_all_diff_units_accepted(self) -> None: """All four diff_unit values are valid.""" from muse.core.merge_engine import MergeEngine for unit in ("three_way", "snapshot", "replay_ours", "replay_theirs"): e = MergeEngine(diff_unit=unit, resolution="escalate") assert e.diff_unit == unit def test_ME_04_all_resolutions_accepted(self) -> None: """All three resolution values are valid.""" from muse.core.merge_engine import MergeEngine for res in ("escalate", "prefer_ours", "prefer_theirs"): e = MergeEngine(diff_unit="three_way", resolution=res) assert e.resolution == res def test_ME_05_invalid_diff_unit_raises(self) -> None: """Invalid diff_unit raises ValueError.""" from muse.core.merge_engine import MergeEngine with pytest.raises((ValueError, TypeError)): MergeEngine(diff_unit="git_merge", resolution="escalate") def test_ME_06_invalid_resolution_raises(self) -> None: """Invalid resolution raises ValueError.""" from muse.core.merge_engine import MergeEngine with pytest.raises((ValueError, TypeError)): MergeEngine(diff_unit="three_way", resolution="ask_nicely") def test_ME_07_strategy_lookup_table_exists(self) -> None: """STRATEGY_MAP maps named strategies to MergeEngine instances.""" from muse.core.merge_engine import STRATEGY_MAP assert "recursive" in STRATEGY_MAP assert "overlay" in STRATEGY_MAP assert "snapshot" in STRATEGY_MAP assert "replay" in STRATEGY_MAP def test_ME_08_recursive_is_three_way_escalate(self) -> None: """recursive == three_way + escalate.""" from muse.core.merge_engine import STRATEGY_MAP e = STRATEGY_MAP["recursive"] assert e.diff_unit == "three_way" assert e.resolution == "escalate" def test_ME_09_overlay_is_snapshot_prefer_theirs(self) -> None: """overlay == snapshot + prefer_theirs.""" from muse.core.merge_engine import STRATEGY_MAP e = STRATEGY_MAP["overlay"] assert e.diff_unit == "snapshot" assert e.resolution == "prefer_theirs" def test_ME_10_snapshot_is_snapshot_escalate(self) -> None: """snapshot == snapshot + escalate.""" from muse.core.merge_engine import STRATEGY_MAP e = STRATEGY_MAP["snapshot"] assert e.diff_unit == "snapshot" assert e.resolution == "escalate" def test_ME_11_replay_is_replay_ours_escalate(self) -> None: """replay == replay_ours + escalate.""" from muse.core.merge_engine import STRATEGY_MAP e = STRATEGY_MAP["replay"] assert e.diff_unit == "replay_ours" assert e.resolution == "escalate" def test_ME_12_old_names_not_in_strategy_map(self) -> None: """state_merge and state_replay are removed — not valid strategy names.""" from muse.core.merge_engine import STRATEGY_MAP assert "state_merge" not in STRATEGY_MAP assert "state_replay" not in STRATEGY_MAP # --------------------------------------------------------------------------- # Group 2 — --strategy CLI flag # --------------------------------------------------------------------------- class TestStrategyFlag: """--strategy must accept the new vocabulary and reject the old.""" def _two_branch_repo(self, tmp_path: pathlib.Path): root, repo_id = _init_repo(tmp_path) a_oid = _write_obj(root, b"file_a base") base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid}) b_oid = _write_obj(root, b"file_a ours") ours_id = _make_commit(root, repo_id, "main", "ours change", {"a.py": b_oid}, parent_id=base_id) feat_oid = _write_obj(root, b"file_b theirs") feat_id = _make_commit(root, repo_id, "feat", "feat commit", {"a.py": a_oid, "b.py": feat_oid}, parent_id=base_id) _checkout(root, "main", {"a.py": b_oid}) return root, repo_id def test_ST_01_strategy_recursive_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "recursive", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_02_strategy_overlay_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "overlay", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_03_strategy_snapshot_accepted(self, tmp_path: pathlib.Path) -> None: """snapshot is the new name for state_merge.""" root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_04_strategy_replay_accepted(self, tmp_path: pathlib.Path) -> None: """replay is the new name for state_replay.""" root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "replay", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_05_strategy_ours_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_06_strategy_theirs_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "theirs", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 def test_ST_07_state_merge_rejected(self, tmp_path: pathlib.Path) -> None: """state_merge is the old name — must be rejected.""" root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "state_merge", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 0 def test_ST_08_state_replay_rejected(self, tmp_path: pathlib.Path) -> None: """state_replay is the old name — must be rejected.""" root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "state_replay", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 0 def test_ST_09_json_output_includes_strategy(self, tmp_path: pathlib.Path) -> None: """JSON output includes the strategy that was used.""" root, _ = self._two_branch_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("strategy") == "snapshot" # --------------------------------------------------------------------------- # Group 3 — --on-conflict flag # --------------------------------------------------------------------------- class TestOnConflictFlag: """--on-conflict must be accepted by muse merge and wired into JSON output.""" def _conflicting_repo(self, tmp_path: pathlib.Path): """Two branches that genuinely conflict on a.py.""" root, repo_id = _init_repo(tmp_path) a_base = _write_obj(root, b"line1\nline2\n") base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_base}) a_ours = _write_obj(root, b"line1\nOURS\n") _make_commit(root, repo_id, "main", "ours", {"a.py": a_ours}, parent_id=base_id) a_theirs = _write_obj(root, b"line1\nTHEIRS\n") _make_commit(root, repo_id, "feat", "theirs", {"a.py": a_theirs}, parent_id=base_id) _checkout(root, "main", {"a.py": a_ours}) return root, repo_id def test_OC_01_on_conflict_flag_exists(self, tmp_path: pathlib.Path) -> None: """--on-conflict is a recognised flag (no argparse error).""" root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"], env=_env(root), ) # argparse unknown-flag produces exit_code 2; anything else means flag was parsed assert result.exit_code != 2 def test_OC_02_on_conflict_escalate_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_OC_03_on_conflict_ours_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_OC_04_on_conflict_theirs_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_OC_05_on_conflict_invalid_rejected(self, tmp_path: pathlib.Path) -> None: root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "random_value", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 0 def test_OC_06_on_conflict_ours_resolves_conflict(self, tmp_path: pathlib.Path) -> None: """--on-conflict ours on a conflicting merge produces no conflicts in JSON.""" root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("conflicts") == [] or data.get("conflict_count") == 0 def test_OC_07_on_conflict_theirs_resolves_conflict(self, tmp_path: pathlib.Path) -> None: """--on-conflict theirs on a conflicting merge produces no conflicts in JSON.""" root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("conflicts") == [] or data.get("conflict_count") == 0 def test_OC_08_escalate_surfaces_conflict(self, tmp_path: pathlib.Path) -> None: """--on-conflict escalate (default) surfaces conflicts normally.""" root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) conflicts = data.get("conflicts", []) conflict_count = data.get("conflict_count", len(conflicts)) assert conflict_count > 0 def test_OC_09_strategy_ours_alias_equals_on_conflict_ours(self, tmp_path: pathlib.Path) -> None: """--strategy ours and --strategy recursive --on-conflict ours produce identical results.""" root, repo_id = self._conflicting_repo(tmp_path) result_alias = runner.invoke( ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"], env=_env(root), ) result_explicit = runner.invoke( ["merge", "feat", "--strategy", "recursive", "--on-conflict", "ours", "--dry-run", "--json"], env=_env(root), ) alias_data = json.loads(result_alias.output) explicit_data = json.loads(result_explicit.output) assert alias_data.get("conflicts") == explicit_data.get("conflicts") def test_OC_10_json_output_includes_on_conflict(self, tmp_path: pathlib.Path) -> None: """JSON output includes the on_conflict value that was used.""" root, _ = self._conflicting_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("on_conflict") == "ours" # --------------------------------------------------------------------------- # Group 4 — --history flag # --------------------------------------------------------------------------- class TestHistoryFlag: """--history must be accepted by muse merge.""" def _clean_merge_repo(self, tmp_path: pathlib.Path): """Two branches with no conflicts.""" root, repo_id = _init_repo(tmp_path) a_oid = _write_obj(root, b"file_a base") base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid}) b_oid = _write_obj(root, b"file_b ours") _make_commit(root, repo_id, "main", "ours", {"a.py": a_oid, "b.py": b_oid}, parent_id=base_id) c_oid = _write_obj(root, b"file_c theirs") _make_commit(root, repo_id, "feat", "feat", {"a.py": a_oid, "c.py": c_oid}, parent_id=base_id) _checkout(root, "main", {"a.py": a_oid, "b.py": b_oid}) return root, repo_id def test_HI_01_history_flag_exists(self, tmp_path: pathlib.Path) -> None: """--history is a recognised flag (no argparse exit_code 2).""" root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "merge", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_HI_02_history_merge_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "merge", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_HI_03_history_squash_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "squash", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_HI_04_history_rebase_accepted(self, tmp_path: pathlib.Path) -> None: root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "rebase", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 2 def test_HI_05_history_invalid_rejected(self, tmp_path: pathlib.Path) -> None: root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "squash_rebase_both", "--dry-run", "--json"], env=_env(root), ) assert result.exit_code != 0 def test_HI_06_history_merge_produces_two_parent_commit(self, tmp_path: pathlib.Path) -> None: """--history merge produces a commit with two parents.""" root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "merge", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 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 def test_HI_07_history_squash_produces_single_parent_commit(self, tmp_path: pathlib.Path) -> None: """--history squash produces a commit with one parent (no merge commit).""" root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "squash", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("exit_code") == 0 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 def test_HI_08_json_output_includes_history(self, tmp_path: pathlib.Path) -> None: """JSON output includes the history mode that was used.""" root, _ = self._clean_merge_repo(tmp_path) result = runner.invoke( ["merge", "feat", "--history", "squash", "--dry-run", "--json"], env=_env(root), ) data = json.loads(result.output) assert data.get("history") == "squash"