"""muse#29 — comprehensive merge strategy TDD: strategy x history x conflict level. Ground-truth suite for "Muse shines at merges": exercises the real ``--strategy`` / ``--history`` matrix (correcting the ticket's own stale names — the three-way conflict-detecting strategy is called ``recursive``, not ``state_merge``) with deliberate conflicts engineered simultaneously at all three levels Muse tracks: file, directory, and symbol. Also covers the octopus (N-way) merge path, which the ticket's original strategy matrix predates and does not mention at all. Real strategy matrix (``muse merge --help``) --------------------------------------------- ``--strategy``: recursive (default, three-way + conflict detection), overlay, snapshot, replay, ours, theirs. ``--history``: merge, squash, rebase. This file's Phase 2 matrix exercises the two strategies whose behaviour differs meaningfully in the presence of real conflicts — ``recursive`` (surfaces conflicts for resolution) and ``overlay`` (auto-resolves, theirs wins) — against all three ``--history`` modes, six cells total. The other four strategies (snapshot, replay, ours, theirs) and the full untouched/convergent/divergent/add-add/delete-modify scenario grid are already covered per-strategy by ``tests/test_phase3_strategy_matrix.py`` (SM_01-24) — not duplicated here. Octopus merge (muse#105) is a structurally different, separate code path: N-way (2+ branches), ignores ``--strategy``/``--history``/``--on-conflict`` entirely, refuses outright on any path collision (no resolution, matching git's own octopus behaviour). Covered here as its own section per the ticket's explicit ask to include it; engine-level coverage already exists in ``tests/test_octopus_merge.py``. Two real bugs were found and fixed while building this suite (TDD, per the ticket's own acceptance criteria — "implementation fixed to make tests pass"): 1. Completing a conflicted merge via ``muse resolve`` + ``muse commit`` always produced a two-parent merge commit, even when ``--history squash``/``--history rebase`` was requested on the original ``muse merge`` call — the history mode was silently dropped the moment a conflict forced completion through ``muse commit`` instead of ``muse merge``'s own clean-path commit-writing. Fixed by threading ``history`` through ``MERGE_STATE.json``. 2. (The ticket's own named regression.) If a resolved conflict's content happened to exactly match HEAD's current snapshot (e.g. the user resolved by keeping "ours"), ``muse commit`` took the "nothing to commit, working tree clean" early return — exit 0, looks benign — but never created the merge commit, never set the second parent, and never cleared ``MERGE_STATE.json``. The repo was left permanently stuck: every subsequent ``muse merge`` re-surfaced the identical already-resolved conflict. Fixed by skipping that early return whenever a merge is actually being completed, regardless of ``--allow-empty``. """ from __future__ import annotations import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() # --------------------------------------------------------------------------- # Phase 1 — Fixtures and helpers # --------------------------------------------------------------------------- def _run(repo: pathlib.Path, *args: str) -> InvokeResult: return runner.invoke(None, list(args), cwd=repo) def _run_ok(repo: pathlib.Path, *args: str) -> InvokeResult: r = _run(repo, *args) assert r.exit_code == 0, f"muse {' '.join(args)} failed: {r.stdout}\n{r.stderr}" return r def _branch_from(repo: pathlib.Path, base: str, name: str) -> None: _run_ok(repo, "checkout", base) _run_ok(repo, "branch", name) _run_ok(repo, "checkout", name) def make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Build a repo with ours (main) / theirs (feat) branches carrying deliberate, simultaneous conflicts at all three levels Muse tracks. Symbol-level (the only level that actually conflicts): calc.py::add — main sets it to "+1", feat sets it to "+100". File-level (non-overlapping regions of the same file — must auto-merge cleanly, no conflict, both sides' edits present): shared.py — main edits alpha(), feat edits beta(). Directory-level (additions + a deletion in the same directory — must auto-merge cleanly, no conflict, every addition present and the deletion applied): dir_a/ — main adds dir_a/main_new.py and deletes dir_a/old.py; feat adds dir_a/feat_new.py; dir_a/keep.py is untouched. """ repo = tmp_path r = _run(repo, "init") assert r.exit_code == 0, r.stderr (repo / "calc.py").write_text("def add(a, b):\n return a + b\n") (repo / "shared.py").write_text( "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'base beta'\n" ) (repo / "dir_a").mkdir() (repo / "dir_a" / "keep.py").write_text("keep = True\n") (repo / "dir_a" / "old.py").write_text("old = True\n") _run_ok(repo, "code", "add", ".") _run_ok(repo, "commit", "-m", "base") _branch_from(repo, "main", "feat") (repo / "calc.py").write_text("def add(a, b):\n return a + b + 100\n") (repo / "shared.py").write_text( "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'feat beta'\n" ) (repo / "dir_a" / "feat_new.py").write_text("feat_new = True\n") _run_ok(repo, "code", "add", ".") _run_ok(repo, "commit", "-m", "feat changes") _run_ok(repo, "checkout", "main") (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n") (repo / "shared.py").write_text( "def alpha():\n return 'main alpha'\n\n\ndef beta():\n return 'base beta'\n" ) (repo / "dir_a" / "main_new.py").write_text("main_new = True\n") (repo / "dir_a" / "old.py").unlink() _run_ok(repo, "code", "add", ".") _run_ok(repo, "code", "add", "-u") _run_ok(repo, "commit", "-m", "main changes") return repo def assert_no_work_lost(repo: pathlib.Path) -> None: """Verify every non-conflicting change from both sides survived the merge. Covers the file-level and directory-level assertions from the ticket's conflict-level matrix: both sides' independent edits to shared.py are present, every directory addition is present, and the directory deletion was applied. """ shared = (repo / "shared.py").read_text() assert "main alpha" in shared, "main's alpha() edit must survive the merge" assert "feat beta" in shared, "feat's beta() edit must survive the merge" assert (repo / "dir_a" / "keep.py").exists(), "untouched dir_a/keep.py must survive" assert (repo / "dir_a" / "main_new.py").exists(), "main's dir_a addition must survive" assert (repo / "dir_a" / "feat_new.py").exists(), "feat's dir_a addition must survive" assert not (repo / "dir_a" / "old.py").exists(), "main's dir_a deletion must be applied" def assert_symbol_conflict_listed(repo: pathlib.Path, expected_path: str = "calc.py::add") -> None: """Verify ``muse conflicts --json`` lists the expected symbol-level conflict.""" import json r = _run_ok(repo, "conflicts", "--json") data = json.loads(r.output) paths = [c["path"] for c in data["conflicts"]] assert expected_path in paths, f"expected {expected_path!r} in conflicts, got {paths}" def assert_merge_history(repo: pathlib.Path, commit_id: str, *, history: str) -> None: """Verify the commit's parent structure matches the ``--history`` mode. ``"merge"`` → two parents (``parent2_commit_id`` set). ``"squash"``/``"rebase"`` → single parent (``parent2_commit_id`` is None). """ from muse.core.commits import read_commit rec = read_commit(repo, commit_id) assert rec is not None, f"commit {commit_id} not found" if history == "merge": assert rec.parent2_commit_id is not None, ( f"--history merge must produce a two-parent commit, got {rec!r}" ) else: assert rec.parent2_commit_id is None, ( f"--history {history} must produce a single-parent commit, got {rec!r}" ) def assert_verify_clean(repo: pathlib.Path) -> None: import json r = _run_ok(repo, "verify", "--json") data = json.loads(r.output) assert data["all_ok"] is True, f"muse verify reported problems: {data}" # --------------------------------------------------------------------------- # Phase 2 — Strategy x History matrix (6 cells), all 3 conflict levels at once # --------------------------------------------------------------------------- class TestRecursiveStrategyMatrix: """recursive (real three-way merge with conflict detection) x history. recursive surfaces the symbol-level conflict for manual resolution in all three history modes — it must never silently auto-resolve. """ @pytest.mark.parametrize("history", ["merge", "squash", "rebase"]) def test_recursive_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None: import json repo = make_conflict_repo(tmp_path) r = _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json") assert r.exit_code == 1, r.output data = json.loads(r.output) assert data["status"] == "conflict" assert data["conflicts"] == ["calc.py::add"], ( "recursive must surface exactly the symbol-level conflict — " "the non-overlapping file/directory changes must auto-merge cleanly" ) assert_symbol_conflict_listed(repo) # Resolve the one real conflict manually, to a value distinct from # both sides, and confirm the resolve stages the merged body (not # conflict-marker text). (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") r = _run_ok(repo, "resolve", "calc.py::add", "--json") resolve_data = json.loads(r.output) assert resolve_data["ready_to_commit"] is True r = _run_ok(repo, "diff", "--staged") assert "<<<<<<<" not in r.output, "staged diff must not contain conflict markers" r = _run_ok(repo, "commit", "-m", "resolve", "--json") commit_data = json.loads(r.output) commit_id = commit_data["commit_id"] assert commit_id is not None assert_no_work_lost(repo) assert "a + b + 101" in (repo / "calc.py").read_text(), ( "the manually-resolved symbol body must be what's committed" ) assert_merge_history(repo, commit_id, history=history) assert_verify_clean(repo) class TestOverlayStrategyMatrix: """overlay (theirs wins on conflict) x history — never pauses for resolution.""" @pytest.mark.parametrize("history", ["merge", "squash", "rebase"]) def test_overlay_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None: import json repo = make_conflict_repo(tmp_path) r = _run_ok(repo, "merge", "feat", "--strategy", "overlay", "--history", history, "--json") data = json.loads(r.output) assert data["status"] == "merged", f"overlay must auto-resolve, got {data}" commit_id = data["commit_id"] assert commit_id is not None # overlay is theirs-wins at the whole-file grain (snapshot overlay, # no base) — calc.py (which theirs touched) ends up as theirs' # content entirely, not a symbol-merged value. assert "a + b + 100" in (repo / "calc.py").read_text() assert_merge_history(repo, commit_id, history=history) assert_verify_clean(repo) # --------------------------------------------------------------------------- # Octopus (N-way) merge — separate code path, no --strategy/--history # --------------------------------------------------------------------------- class TestOctopusMerge: """muse#105 — N-way merge. Ignores --strategy/--history entirely; refuses outright on any path collision (no resolution, like git).""" def test_octopus_clean_three_way_no_work_lost(self, tmp_path: pathlib.Path) -> None: import json repo = tmp_path r = _run(repo, "init") assert r.exit_code == 0, r.stderr (repo / "base.py").write_text("base = 1\n") _run_ok(repo, "code", "add", ".") _run_ok(repo, "commit", "-m", "base") for name, fname in (("feat/a", "a.py"), ("feat/b", "b.py"), ("feat/c", "c.py")): _branch_from(repo, "main", name) (repo / fname).write_text(f"{fname} = True\n") _run_ok(repo, "code", "add", fname) _run_ok(repo, "commit", "-m", f"add {fname}") _run_ok(repo, "checkout", "main") r = _run_ok(repo, "merge", "feat/a", "feat/b", "feat/c", "--json") data = json.loads(r.output) assert data["status"] == "merged" assert data["commit_id"] is not None for fname in ("a.py", "b.py", "c.py"): assert (repo / fname).exists(), f"{fname} from octopus branch must survive" from muse.core.commits import read_commit rec = read_commit(repo, data["commit_id"]) assert rec is not None assert len(rec.parent_commit_ids) == 4, "octopus commit must have 4 parents (main + 3 branches)" assert_verify_clean(repo) def test_octopus_refuses_on_path_collision_no_partial_commit( self, tmp_path: pathlib.Path ) -> None: import json repo = tmp_path r = _run(repo, "init") assert r.exit_code == 0, r.stderr (repo / "base.py").write_text("base = 1\n") _run_ok(repo, "code", "add", ".") _run_ok(repo, "commit", "-m", "base") _branch_from(repo, "main", "feat/a") (repo / "shared.py").write_text("shared = 'a'\n") _run_ok(repo, "code", "add", "shared.py") _run_ok(repo, "commit", "-m", "a touches shared") _run_ok(repo, "checkout", "main") _branch_from(repo, "main", "feat/b") (repo / "shared.py").write_text("shared = 'b'\n") _run_ok(repo, "code", "add", "shared.py") _run_ok(repo, "commit", "-m", "b touches shared") head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip() _run_ok(repo, "checkout", "main") r = _run(repo, "merge", "feat/a", "feat/b", "--json") assert r.exit_code != 0 data = json.loads(r.output) assert data["status"] == "conflict" assert "shared.py" in data["conflict"]["paths"] # No partial commit, no invented resolution — main's ref must be # byte-for-byte unmoved, matching git's own octopus behaviour. head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip() assert head_after == head_before, "octopus refusal must not move the branch ref at all" assert_verify_clean(repo) # --------------------------------------------------------------------------- # Phase 3 — Regression tests for known failure modes # --------------------------------------------------------------------------- class TestAllowEmptySnapshotRegression: """muse#29's named regression: resolving a conflict to content identical to HEAD must still finalize the merge — not silently no-op.""" def test_resolve_matching_ours_still_completes_merge_without_allow_empty( self, tmp_path: pathlib.Path ) -> None: import json repo = make_conflict_repo(tmp_path) _run(repo, "merge", "feat", "--strategy", "recursive", "--json") # Resolve by keeping "ours" exactly — content ends up identical to # what HEAD already has, except for the dir_a/shared.py changes # that already auto-merged cleanly. The symbol itself matches ours. (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n") _run_ok(repo, "resolve", "calc.py::add", "--json") r = _run_ok(repo, "commit", "-m", "resolve", "--json") data = json.loads(r.output) assert data.get("clean") is not True, ( "must not take the deceptive 'nothing to commit, working tree " "clean' early return while a merge is being completed" ) assert data["commit_id"] is not None, "merge commit must actually be written" assert data["parent2_commit_id"] is not None, ( "the second parent must be recorded even when resolved content matches ours" ) assert not (repo / ".muse" / "MERGE_STATE.json").exists(), ( "MERGE_STATE.json must be cleared once the merge is finalized" ) # The repo must not be stuck in a dangling merge-in-progress state. r = _run_ok(repo, "status", "--json") status_data = json.loads(r.output) assert status_data["merge_in_progress"] is False, ( f"repo must not be stuck mid-merge after a successful finalize: {status_data}" ) class TestDryRunNoMutation: """muse merge --dry-run must not mutate any state.""" def test_dry_run_mutates_nothing(self, tmp_path: pathlib.Path) -> None: repo = make_conflict_repo(tmp_path) head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text() calc_before = (repo / "calc.py").read_text() # Dry-run on a genuinely conflicting merge correctly reports exit 1 # (a predicted conflict) — it must still mutate nothing. r = _run(repo, "merge", "feat", "--strategy", "recursive", "--dry-run", "--json") assert r.exit_code == 1, r.output import json data = json.loads(r.output) assert data["status"] == "conflict" assert data["dry_run"] is True assert not (repo / ".muse" / "MERGE_STATE.json").exists(), ( "--dry-run must not write MERGE_STATE.json" ) head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text() assert head_after == head_before, "--dry-run must not move the branch ref" calc_after = (repo / "calc.py").read_text() assert calc_after == calc_before, "--dry-run must not touch the working tree" r = _run_ok(repo, "reflog", "--json") reflog_data = json.loads(r.output) operations = [e.get("operation", "") for e in reflog_data.get("entries", [])] assert not any("merge" in op.lower() for op in operations), ( "--dry-run must not write a reflog entry" ) class TestHarmonyLearnsFromResolution: """Harmony autoupdate (on by default) must learn from a resolved conflict.""" def test_harmony_records_resolution_after_manual_resolve( self, tmp_path: pathlib.Path ) -> None: import json repo = make_conflict_repo(tmp_path) _run(repo, "merge", "feat", "--strategy", "recursive", "--json") (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") _run_ok(repo, "resolve", "calc.py::add", "--json") _run_ok(repo, "commit", "-m", "resolve", "--json") r = _run_ok(repo, "harmony", "list", "--json") patterns = json.loads(r.output)["patterns"] assert len(patterns) == 1 assert patterns[0]["resolution_count"] == 1 class TestHistoryModeSurvivesConflictCompletion: """muse#29 fix: --history squash/rebase must not be silently dropped when the merge pauses for a real conflict.""" @pytest.mark.parametrize("history", ["squash", "rebase"]) def test_history_mode_honoured_after_manual_resolve( self, tmp_path: pathlib.Path, history: str ) -> None: import json repo = make_conflict_repo(tmp_path) _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json") (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") _run_ok(repo, "resolve", "calc.py::add", "--json") r = _run_ok(repo, "commit", "-m", "resolve", "--json") data = json.loads(r.output) assert data["parent2_commit_id"] is None, ( f"--history {history} must produce a single-parent commit even " f"after a conflict pause, got parent2={data['parent2_commit_id']!r}" )