test_phase6_unified_merge_engine.py
python
sha256:ecfc7b5d19db951f256942ac0908b53d55a2da37c6cd1e6cf85b4a6088870865
feat(phase6): unified MergeEngine code path via run_merge()
Sonnet 4.6
patch
21 hours ago
| 1 | """Phase 6 — Unified MergeEngine code path (issue #86). |
| 2 | |
| 3 | Verifies that run_merge() produces correct results across all four diff_units |
| 4 | and all three resolution policies, both with and without a domain plugin, |
| 5 | and that the musehub execute_merge_strategy() canonical router delegates to |
| 6 | run_merge() correctly. |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import sys |
| 11 | import types |
| 12 | |
| 13 | import pytest |
| 14 | |
| 15 | from muse.core.merge_engine import MergeEngine, STRATEGY_MAP, run_merge |
| 16 | |
| 17 | |
| 18 | # ───────────────────────────────────────────────────────────────────────────── |
| 19 | # Fixtures / helpers |
| 20 | # ───────────────────────────────────────────────────────────────────────────── |
| 21 | |
| 22 | def _oid(tag: str) -> str: |
| 23 | return f"sha256:{'a' * 60}{tag.ljust(4, '0')[:4]}" |
| 24 | |
| 25 | |
| 26 | BASE = { |
| 27 | "a.py": _oid("b001"), |
| 28 | "b.py": _oid("b002"), |
| 29 | "c.py": _oid("b003"), |
| 30 | } |
| 31 | |
| 32 | OURS = { |
| 33 | "a.py": _oid("o001"), # modified |
| 34 | "b.py": _oid("b002"), # unchanged |
| 35 | "c.py": _oid("b003"), # unchanged |
| 36 | "d.py": _oid("o004"), # ours-only addition |
| 37 | } |
| 38 | |
| 39 | THEIRS = { |
| 40 | "a.py": _oid("t001"), # modified (conflict with ours) |
| 41 | "b.py": _oid("t002"), # modified (ours unchanged → clean take) |
| 42 | "c.py": _oid("b003"), # unchanged |
| 43 | "e.py": _oid("t005"), # theirs-only addition |
| 44 | } |
| 45 | |
| 46 | |
| 47 | # ───────────────────────────────────────────────────────────────────────────── |
| 48 | # ME_01 — three_way / escalate: conflicts surfaced, clean changes applied |
| 49 | # ───────────────────────────────────────────────────────────────────────────── |
| 50 | |
| 51 | def test_ME_01_three_way_escalate_conflicts(): |
| 52 | engine = MergeEngine(diff_unit="three_way", resolution="escalate") |
| 53 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 54 | # a.py diverged on both sides → conflict |
| 55 | assert "a.py" in result.conflicts |
| 56 | # b.py only theirs changed → clean |
| 57 | assert "b.py" not in result.conflicts |
| 58 | assert result.merged["files"]["b.py"] == THEIRS["b.py"] |
| 59 | # ours-only addition present |
| 60 | assert result.merged["files"]["d.py"] == OURS["d.py"] |
| 61 | # theirs-only addition present |
| 62 | assert result.merged["files"]["e.py"] == THEIRS["e.py"] |
| 63 | |
| 64 | |
| 65 | # ───────────────────────────────────────────────────────────────────────────── |
| 66 | # ME_02 — three_way / prefer_ours: no conflicts, ours wins |
| 67 | # ───────────────────────────────────────────────────────────────────────────── |
| 68 | |
| 69 | def test_ME_02_three_way_prefer_ours(): |
| 70 | engine = MergeEngine(diff_unit="three_way", resolution="prefer_ours") |
| 71 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 72 | assert result.conflicts == [] |
| 73 | assert result.merged["files"]["a.py"] == OURS["a.py"] # conflict → ours wins |
| 74 | assert result.merged["files"]["b.py"] == THEIRS["b.py"] # clean theirs still applied |
| 75 | |
| 76 | |
| 77 | # ───────────────────────────────────────────────────────────────────────────── |
| 78 | # ME_03 — three_way / prefer_theirs: no conflicts, theirs wins |
| 79 | # ───────────────────────────────────────────────────────────────────────────── |
| 80 | |
| 81 | def test_ME_03_three_way_prefer_theirs(): |
| 82 | engine = MergeEngine(diff_unit="three_way", resolution="prefer_theirs") |
| 83 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 84 | assert result.conflicts == [] |
| 85 | assert result.merged["files"]["a.py"] == THEIRS["a.py"] |
| 86 | |
| 87 | |
| 88 | # ───────────────────────────────────────────────────────────────────────────── |
| 89 | # ME_04 — snapshot / prefer_theirs (overlay): theirs always wins |
| 90 | # ───────────────────────────────────────────────────────────────────────────── |
| 91 | |
| 92 | def test_ME_04_snapshot_prefer_theirs(): |
| 93 | engine = MergeEngine(diff_unit="snapshot", resolution="prefer_theirs") |
| 94 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 95 | assert result.conflicts == [] |
| 96 | # theirs wins on a.py (conflict in snapshot means same in snapshot = both have it) |
| 97 | assert result.merged["files"]["a.py"] == THEIRS["a.py"] |
| 98 | # ours-only d.py present (not in theirs, keep from ours via apply_merge) |
| 99 | assert "d.py" in result.merged["files"] |
| 100 | # theirs-only e.py present |
| 101 | assert "e.py" in result.merged["files"] |
| 102 | |
| 103 | |
| 104 | # ───────────────────────────────────────────────────────────────────────────── |
| 105 | # ME_05 — snapshot / escalate: conflicts surfaced at snapshot level (no base) |
| 106 | # ───────────────────────────────────────────────────────────────────────────── |
| 107 | |
| 108 | def test_ME_05_snapshot_escalate(): |
| 109 | engine = MergeEngine(diff_unit="snapshot", resolution="escalate") |
| 110 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 111 | # a.py differs in ours vs theirs with empty base → every differing path conflicts |
| 112 | assert "a.py" in result.conflicts |
| 113 | |
| 114 | |
| 115 | # ───────────────────────────────────────────────────────────────────────────── |
| 116 | # ME_06 — replay_ours: ours delta applied onto theirs state |
| 117 | # ───────────────────────────────────────────────────────────────────────────── |
| 118 | |
| 119 | def test_ME_06_replay_ours(): |
| 120 | engine = MergeEngine(diff_unit="replay_ours", resolution="escalate") |
| 121 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 122 | # ours changed a.py (base→ours delta), so a.py in result = ours version |
| 123 | assert result.merged["files"]["a.py"] == OURS["a.py"] |
| 124 | # b.py: ours unchanged, theirs changed → theirs' version in merged base (theirs state kept) |
| 125 | assert result.merged["files"]["b.py"] == THEIRS["b.py"] |
| 126 | # d.py: ours added (not in base) → in result |
| 127 | assert result.merged["files"]["d.py"] == OURS["d.py"] |
| 128 | |
| 129 | |
| 130 | # ───────────────────────────────────────────────────────────────────────────── |
| 131 | # ME_07 — replay_theirs: theirs delta applied onto ours state |
| 132 | # ───────────────────────────────────────────────────────────────────────────── |
| 133 | |
| 134 | def test_ME_07_replay_theirs(): |
| 135 | engine = MergeEngine(diff_unit="replay_theirs", resolution="escalate") |
| 136 | result = run_merge(BASE, OURS, THEIRS, engine) |
| 137 | # theirs changed a.py → a.py in result = theirs version |
| 138 | assert result.merged["files"]["a.py"] == THEIRS["a.py"] |
| 139 | # d.py: ours added → preserved in ours state base |
| 140 | assert result.merged["files"]["d.py"] == OURS["d.py"] |
| 141 | # e.py: theirs added (base→theirs delta) → in result |
| 142 | assert result.merged["files"]["e.py"] == THEIRS["e.py"] |
| 143 | |
| 144 | |
| 145 | # ───────────────────────────────────────────────────────────────────────────── |
| 146 | # ME_08 — STRATEGY_MAP entries produce correct engines |
| 147 | # ───────────────────────────────────────────────────────────────────────────── |
| 148 | |
| 149 | def test_ME_08_strategy_map_entries(): |
| 150 | assert STRATEGY_MAP["recursive"] == MergeEngine("three_way", "escalate") |
| 151 | assert STRATEGY_MAP["overlay"] == MergeEngine("snapshot", "prefer_theirs") |
| 152 | assert STRATEGY_MAP["snapshot"] == MergeEngine("snapshot", "escalate") |
| 153 | assert STRATEGY_MAP["replay"] == MergeEngine("replay_ours", "escalate") |
| 154 | assert STRATEGY_MAP["ours"] == MergeEngine("three_way", "prefer_ours") |
| 155 | assert STRATEGY_MAP["theirs"] == MergeEngine("three_way", "prefer_theirs") |
| 156 | |
| 157 | |
| 158 | # ───────────────────────────────────────────────────────────────────────────── |
| 159 | # ME_09 — clean merge: no conflicts regardless of diff_unit |
| 160 | # ───────────────────────────────────────────────────────────────────────────── |
| 161 | |
| 162 | def test_ME_09_clean_merge_no_conflicts(): |
| 163 | base_m = {"a.py": _oid("b1")} |
| 164 | ours_m = {"a.py": _oid("b1"), "b.py": _oid("o2")} # only ours adds b.py |
| 165 | theirs_m = {"a.py": _oid("b1"), "c.py": _oid("t3")} # only theirs adds c.py |
| 166 | |
| 167 | for diff_unit in ("three_way", "snapshot", "replay_ours", "replay_theirs"): |
| 168 | engine = MergeEngine(diff_unit=diff_unit, resolution="escalate") |
| 169 | result = run_merge(base_m, ours_m, theirs_m, engine) |
| 170 | assert result.conflicts == [], f"{diff_unit} produced unexpected conflicts" |
| 171 | |
| 172 | |
| 173 | # ───────────────────────────────────────────────────────────────────────────── |
| 174 | # ME_10 — musehub execute_merge_strategy delegates canonical routes to run_merge |
| 175 | # ───────────────────────────────────────────────────────────────────────────── |
| 176 | |
| 177 | def test_ME_10_musehub_overlay_via_run_merge(): |
| 178 | """execute_merge_strategy('overlay') uses run_merge and produces correct manifest.""" |
| 179 | sys.path.insert(0, "/Users/gabriel/ecosystem/musehub") |
| 180 | from musehub.services.proposal_merge_strategies import execute_merge_strategy |
| 181 | |
| 182 | to_m = {"a.py": _oid("t1"), "b.py": _oid("t2")} |
| 183 | from_m = {"a.py": _oid("f1"), "c.py": _oid("f3")} |
| 184 | result = execute_merge_strategy("overlay", to_m, from_m) |
| 185 | # overlay = snapshot + prefer_theirs → from_branch wins on a.py |
| 186 | assert result.manifest["a.py"] == _oid("f1") |
| 187 | # b.py kept from to_branch (ours) |
| 188 | assert result.manifest["b.py"] == _oid("t2") |
| 189 | # c.py from from_branch added |
| 190 | assert result.manifest["c.py"] == _oid("f3") |
| 191 | assert result.conflicts == [] |
| 192 | |
| 193 | |
| 194 | def test_ME_11_musehub_recursive_surfaces_conflicts(): |
| 195 | """execute_merge_strategy('recursive') uses run_merge and surfaces conflicts.""" |
| 196 | sys.path.insert(0, "/Users/gabriel/ecosystem/musehub") |
| 197 | from musehub.services.proposal_merge_strategies import execute_merge_strategy, ConflictEntry |
| 198 | |
| 199 | base_m = {"a.py": _oid("b1")} |
| 200 | to_m = {"a.py": _oid("t1")} # to_branch changed a.py |
| 201 | from_m = {"a.py": _oid("f1")} # from_branch also changed a.py → conflict |
| 202 | result = execute_merge_strategy("recursive", to_m, from_m, ancestor_manifest=base_m) |
| 203 | assert len(result.conflicts) == 1 |
| 204 | assert result.conflicts[0].path == "a.py" |
| 205 | assert result.conflicts[0].resolution == "manual_required" |
| 206 | |
| 207 | |
| 208 | def test_ME_12_musehub_replay_applies_from_delta(): |
| 209 | """execute_merge_strategy('replay') applies from_branch delta onto to_branch state.""" |
| 210 | sys.path.insert(0, "/Users/gabriel/ecosystem/musehub") |
| 211 | from musehub.services.proposal_merge_strategies import execute_merge_strategy |
| 212 | |
| 213 | base_m = {"a.py": _oid("b1"), "b.py": _oid("b2")} |
| 214 | to_m = {"a.py": _oid("b1"), "b.py": _oid("t2")} # to changed b.py only |
| 215 | from_m = {"a.py": _oid("f1"), "b.py": _oid("b2")} # from changed a.py only |
| 216 | result = execute_merge_strategy("replay", to_m, from_m, ancestor_manifest=base_m) |
| 217 | # replay_ours: ours is to_m, theirs is from_m → apply ours-delta (base→to) onto theirs state |
| 218 | # ours delta: b.py changed → apply b.py change onto from_m state |
| 219 | assert result.conflicts == [] |
File History
1 commit
sha256:ecfc7b5d19db951f256942ac0908b53d55a2da37c6cd1e6cf85b4a6088870865
feat(phase6): unified MergeEngine code path via run_merge()
Sonnet 4.6
patch
21 hours ago