gabriel / muse public
test_merge_matrix.py python
465 lines 19.9 KB
Raw
sha256:26088198afa1ef5bf63a4b37349344742d40762144235e27ef26f3e7d9856cae test: comprehensive merge strategy x history x conflict-lev… Sonnet 5 minor ⚠ breaking 1 day ago
1 """muse#29 — comprehensive merge strategy TDD: strategy x history x conflict level.
2
3 Ground-truth suite for "Muse shines at merges": exercises the real
4 ``--strategy`` / ``--history`` matrix (correcting the ticket's own stale
5 names — the three-way conflict-detecting strategy is called ``recursive``,
6 not ``state_merge``) with deliberate conflicts engineered simultaneously at
7 all three levels Muse tracks: file, directory, and symbol. Also covers the
8 octopus (N-way) merge path, which the ticket's original strategy matrix
9 predates and does not mention at all.
10
11 Real strategy matrix (``muse merge --help``)
12 ---------------------------------------------
13 ``--strategy``: recursive (default, three-way + conflict detection),
14 overlay, snapshot, replay, ours, theirs.
15 ``--history``: merge, squash, rebase.
16
17 This file's Phase 2 matrix exercises the two strategies whose behaviour
18 differs meaningfully in the presence of real conflicts — ``recursive``
19 (surfaces conflicts for resolution) and ``overlay`` (auto-resolves, theirs
20 wins) — against all three ``--history`` modes, six cells total. The other
21 four strategies (snapshot, replay, ours, theirs) and the full
22 untouched/convergent/divergent/add-add/delete-modify scenario grid are
23 already covered per-strategy by ``tests/test_phase3_strategy_matrix.py``
24 (SM_01-24) — not duplicated here.
25
26 Octopus merge (muse#105) is a structurally different, separate code path:
27 N-way (2+ branches), ignores ``--strategy``/``--history``/``--on-conflict``
28 entirely, refuses outright on any path collision (no resolution, matching
29 git's own octopus behaviour). Covered here as its own section per the
30 ticket's explicit ask to include it; engine-level coverage already exists
31 in ``tests/test_octopus_merge.py``.
32
33 Two real bugs were found and fixed while building this suite (TDD, per the
34 ticket's own acceptance criteria — "implementation fixed to make tests
35 pass"):
36
37 1. Completing a conflicted merge via ``muse resolve`` + ``muse commit``
38 always produced a two-parent merge commit, even when ``--history
39 squash``/``--history rebase`` was requested on the original ``muse
40 merge`` call — the history mode was silently dropped the moment a
41 conflict forced completion through ``muse commit`` instead of ``muse
42 merge``'s own clean-path commit-writing. Fixed by threading ``history``
43 through ``MERGE_STATE.json``.
44
45 2. (The ticket's own named regression.) If a resolved conflict's content
46 happened to exactly match HEAD's current snapshot (e.g. the user
47 resolved by keeping "ours"), ``muse commit`` took the "nothing to
48 commit, working tree clean" early return — exit 0, looks benign — but
49 never created the merge commit, never set the second parent, and never
50 cleared ``MERGE_STATE.json``. The repo was left permanently stuck:
51 every subsequent ``muse merge`` re-surfaced the identical
52 already-resolved conflict. Fixed by skipping that early return whenever
53 a merge is actually being completed, regardless of ``--allow-empty``.
54 """
55 from __future__ import annotations
56
57 import os
58 import pathlib
59
60 import pytest
61
62 from tests.cli_test_helper import CliRunner, InvokeResult
63
64 runner = CliRunner()
65
66
67 # ---------------------------------------------------------------------------
68 # Phase 1 — Fixtures and helpers
69 # ---------------------------------------------------------------------------
70
71
72 def _run(repo: pathlib.Path, *args: str) -> InvokeResult:
73 return runner.invoke(None, list(args), cwd=repo)
74
75
76 def _run_ok(repo: pathlib.Path, *args: str) -> InvokeResult:
77 r = _run(repo, *args)
78 assert r.exit_code == 0, f"muse {' '.join(args)} failed: {r.stdout}\n{r.stderr}"
79 return r
80
81
82 def _branch_from(repo: pathlib.Path, base: str, name: str) -> None:
83 _run_ok(repo, "checkout", base)
84 _run_ok(repo, "branch", name)
85 _run_ok(repo, "checkout", name)
86
87
88 def make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path:
89 """Build a repo with ours (main) / theirs (feat) branches carrying
90 deliberate, simultaneous conflicts at all three levels Muse tracks.
91
92 Symbol-level (the only level that actually conflicts):
93 calc.py::add — main sets it to "+1", feat sets it to "+100".
94
95 File-level (non-overlapping regions of the same file — must auto-merge
96 cleanly, no conflict, both sides' edits present):
97 shared.py — main edits alpha(), feat edits beta().
98
99 Directory-level (additions + a deletion in the same directory — must
100 auto-merge cleanly, no conflict, every addition present and the
101 deletion applied):
102 dir_a/ — main adds dir_a/main_new.py and deletes dir_a/old.py;
103 feat adds dir_a/feat_new.py; dir_a/keep.py is untouched.
104 """
105 repo = tmp_path
106 r = _run(repo, "init")
107 assert r.exit_code == 0, r.stderr
108
109 (repo / "calc.py").write_text("def add(a, b):\n return a + b\n")
110 (repo / "shared.py").write_text(
111 "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'base beta'\n"
112 )
113 (repo / "dir_a").mkdir()
114 (repo / "dir_a" / "keep.py").write_text("keep = True\n")
115 (repo / "dir_a" / "old.py").write_text("old = True\n")
116 _run_ok(repo, "code", "add", ".")
117 _run_ok(repo, "commit", "-m", "base")
118
119 _branch_from(repo, "main", "feat")
120 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 100\n")
121 (repo / "shared.py").write_text(
122 "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'feat beta'\n"
123 )
124 (repo / "dir_a" / "feat_new.py").write_text("feat_new = True\n")
125 _run_ok(repo, "code", "add", ".")
126 _run_ok(repo, "commit", "-m", "feat changes")
127
128 _run_ok(repo, "checkout", "main")
129 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n")
130 (repo / "shared.py").write_text(
131 "def alpha():\n return 'main alpha'\n\n\ndef beta():\n return 'base beta'\n"
132 )
133 (repo / "dir_a" / "main_new.py").write_text("main_new = True\n")
134 (repo / "dir_a" / "old.py").unlink()
135 _run_ok(repo, "code", "add", ".")
136 _run_ok(repo, "code", "add", "-u")
137 _run_ok(repo, "commit", "-m", "main changes")
138
139 return repo
140
141
142 def assert_no_work_lost(repo: pathlib.Path) -> None:
143 """Verify every non-conflicting change from both sides survived the merge.
144
145 Covers the file-level and directory-level assertions from the ticket's
146 conflict-level matrix: both sides' independent edits to shared.py are
147 present, every directory addition is present, and the directory
148 deletion was applied.
149 """
150 shared = (repo / "shared.py").read_text()
151 assert "main alpha" in shared, "main's alpha() edit must survive the merge"
152 assert "feat beta" in shared, "feat's beta() edit must survive the merge"
153
154 assert (repo / "dir_a" / "keep.py").exists(), "untouched dir_a/keep.py must survive"
155 assert (repo / "dir_a" / "main_new.py").exists(), "main's dir_a addition must survive"
156 assert (repo / "dir_a" / "feat_new.py").exists(), "feat's dir_a addition must survive"
157 assert not (repo / "dir_a" / "old.py").exists(), "main's dir_a deletion must be applied"
158
159
160 def assert_symbol_conflict_listed(repo: pathlib.Path, expected_path: str = "calc.py::add") -> None:
161 """Verify ``muse conflicts --json`` lists the expected symbol-level conflict."""
162 import json
163 r = _run_ok(repo, "conflicts", "--json")
164 data = json.loads(r.output)
165 paths = [c["path"] for c in data["conflicts"]]
166 assert expected_path in paths, f"expected {expected_path!r} in conflicts, got {paths}"
167
168
169 def assert_merge_history(repo: pathlib.Path, commit_id: str, *, history: str) -> None:
170 """Verify the commit's parent structure matches the ``--history`` mode.
171
172 ``"merge"`` → two parents (``parent2_commit_id`` set).
173 ``"squash"``/``"rebase"`` → single parent (``parent2_commit_id`` is None).
174 """
175 from muse.core.commits import read_commit
176 rec = read_commit(repo, commit_id)
177 assert rec is not None, f"commit {commit_id} not found"
178 if history == "merge":
179 assert rec.parent2_commit_id is not None, (
180 f"--history merge must produce a two-parent commit, got {rec!r}"
181 )
182 else:
183 assert rec.parent2_commit_id is None, (
184 f"--history {history} must produce a single-parent commit, got {rec!r}"
185 )
186
187
188 def assert_verify_clean(repo: pathlib.Path) -> None:
189 import json
190 r = _run_ok(repo, "verify", "--json")
191 data = json.loads(r.output)
192 assert data["all_ok"] is True, f"muse verify reported problems: {data}"
193
194
195 # ---------------------------------------------------------------------------
196 # Phase 2 — Strategy x History matrix (6 cells), all 3 conflict levels at once
197 # ---------------------------------------------------------------------------
198
199
200 class TestRecursiveStrategyMatrix:
201 """recursive (real three-way merge with conflict detection) x history.
202
203 recursive surfaces the symbol-level conflict for manual resolution in
204 all three history modes — it must never silently auto-resolve.
205 """
206
207 @pytest.mark.parametrize("history", ["merge", "squash", "rebase"])
208 def test_recursive_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None:
209 import json
210 repo = make_conflict_repo(tmp_path)
211
212 r = _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json")
213 assert r.exit_code == 1, r.output
214 data = json.loads(r.output)
215 assert data["status"] == "conflict"
216 assert data["conflicts"] == ["calc.py::add"], (
217 "recursive must surface exactly the symbol-level conflict — "
218 "the non-overlapping file/directory changes must auto-merge cleanly"
219 )
220
221 assert_symbol_conflict_listed(repo)
222
223 # Resolve the one real conflict manually, to a value distinct from
224 # both sides, and confirm the resolve stages the merged body (not
225 # conflict-marker text).
226 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n")
227 r = _run_ok(repo, "resolve", "calc.py::add", "--json")
228 resolve_data = json.loads(r.output)
229 assert resolve_data["ready_to_commit"] is True
230
231 r = _run_ok(repo, "diff", "--staged")
232 assert "<<<<<<<" not in r.output, "staged diff must not contain conflict markers"
233
234 r = _run_ok(repo, "commit", "-m", "resolve", "--json")
235 commit_data = json.loads(r.output)
236 commit_id = commit_data["commit_id"]
237 assert commit_id is not None
238
239 assert_no_work_lost(repo)
240 assert "a + b + 101" in (repo / "calc.py").read_text(), (
241 "the manually-resolved symbol body must be what's committed"
242 )
243 assert_merge_history(repo, commit_id, history=history)
244 assert_verify_clean(repo)
245
246
247 class TestOverlayStrategyMatrix:
248 """overlay (theirs wins on conflict) x history — never pauses for resolution."""
249
250 @pytest.mark.parametrize("history", ["merge", "squash", "rebase"])
251 def test_overlay_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None:
252 import json
253 repo = make_conflict_repo(tmp_path)
254
255 r = _run_ok(repo, "merge", "feat", "--strategy", "overlay", "--history", history, "--json")
256 data = json.loads(r.output)
257 assert data["status"] == "merged", f"overlay must auto-resolve, got {data}"
258 commit_id = data["commit_id"]
259 assert commit_id is not None
260
261 # overlay is theirs-wins at the whole-file grain (snapshot overlay,
262 # no base) — calc.py (which theirs touched) ends up as theirs'
263 # content entirely, not a symbol-merged value.
264 assert "a + b + 100" in (repo / "calc.py").read_text()
265
266 assert_merge_history(repo, commit_id, history=history)
267 assert_verify_clean(repo)
268
269
270 # ---------------------------------------------------------------------------
271 # Octopus (N-way) merge — separate code path, no --strategy/--history
272 # ---------------------------------------------------------------------------
273
274
275 class TestOctopusMerge:
276 """muse#105 — N-way merge. Ignores --strategy/--history entirely;
277 refuses outright on any path collision (no resolution, like git)."""
278
279 def test_octopus_clean_three_way_no_work_lost(self, tmp_path: pathlib.Path) -> None:
280 import json
281 repo = tmp_path
282 r = _run(repo, "init")
283 assert r.exit_code == 0, r.stderr
284 (repo / "base.py").write_text("base = 1\n")
285 _run_ok(repo, "code", "add", ".")
286 _run_ok(repo, "commit", "-m", "base")
287
288 for name, fname in (("feat/a", "a.py"), ("feat/b", "b.py"), ("feat/c", "c.py")):
289 _branch_from(repo, "main", name)
290 (repo / fname).write_text(f"{fname} = True\n")
291 _run_ok(repo, "code", "add", fname)
292 _run_ok(repo, "commit", "-m", f"add {fname}")
293
294 _run_ok(repo, "checkout", "main")
295 r = _run_ok(repo, "merge", "feat/a", "feat/b", "feat/c", "--json")
296 data = json.loads(r.output)
297 assert data["status"] == "merged"
298 assert data["commit_id"] is not None
299
300 for fname in ("a.py", "b.py", "c.py"):
301 assert (repo / fname).exists(), f"{fname} from octopus branch must survive"
302
303 from muse.core.commits import read_commit
304 rec = read_commit(repo, data["commit_id"])
305 assert rec is not None
306 assert len(rec.parent_commit_ids) == 4, "octopus commit must have 4 parents (main + 3 branches)"
307
308 assert_verify_clean(repo)
309
310 def test_octopus_refuses_on_path_collision_no_partial_commit(
311 self, tmp_path: pathlib.Path
312 ) -> None:
313 import json
314 repo = tmp_path
315 r = _run(repo, "init")
316 assert r.exit_code == 0, r.stderr
317 (repo / "base.py").write_text("base = 1\n")
318 _run_ok(repo, "code", "add", ".")
319 _run_ok(repo, "commit", "-m", "base")
320
321 _branch_from(repo, "main", "feat/a")
322 (repo / "shared.py").write_text("shared = 'a'\n")
323 _run_ok(repo, "code", "add", "shared.py")
324 _run_ok(repo, "commit", "-m", "a touches shared")
325
326 _run_ok(repo, "checkout", "main")
327 _branch_from(repo, "main", "feat/b")
328 (repo / "shared.py").write_text("shared = 'b'\n")
329 _run_ok(repo, "code", "add", "shared.py")
330 _run_ok(repo, "commit", "-m", "b touches shared")
331
332 head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
333
334 _run_ok(repo, "checkout", "main")
335 r = _run(repo, "merge", "feat/a", "feat/b", "--json")
336 assert r.exit_code != 0
337 data = json.loads(r.output)
338 assert data["status"] == "conflict"
339 assert "shared.py" in data["conflict"]["paths"]
340
341 # No partial commit, no invented resolution — main's ref must be
342 # byte-for-byte unmoved, matching git's own octopus behaviour.
343 head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
344 assert head_after == head_before, "octopus refusal must not move the branch ref at all"
345 assert_verify_clean(repo)
346
347
348 # ---------------------------------------------------------------------------
349 # Phase 3 — Regression tests for known failure modes
350 # ---------------------------------------------------------------------------
351
352
353 class TestAllowEmptySnapshotRegression:
354 """muse#29's named regression: resolving a conflict to content identical
355 to HEAD must still finalize the merge — not silently no-op."""
356
357 def test_resolve_matching_ours_still_completes_merge_without_allow_empty(
358 self, tmp_path: pathlib.Path
359 ) -> None:
360 import json
361 repo = make_conflict_repo(tmp_path)
362 _run(repo, "merge", "feat", "--strategy", "recursive", "--json")
363
364 # Resolve by keeping "ours" exactly — content ends up identical to
365 # what HEAD already has, except for the dir_a/shared.py changes
366 # that already auto-merged cleanly. The symbol itself matches ours.
367 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n")
368 _run_ok(repo, "resolve", "calc.py::add", "--json")
369
370 r = _run_ok(repo, "commit", "-m", "resolve", "--json")
371 data = json.loads(r.output)
372 assert data.get("clean") is not True, (
373 "must not take the deceptive 'nothing to commit, working tree "
374 "clean' early return while a merge is being completed"
375 )
376 assert data["commit_id"] is not None, "merge commit must actually be written"
377 assert data["parent2_commit_id"] is not None, (
378 "the second parent must be recorded even when resolved content matches ours"
379 )
380 assert not (repo / ".muse" / "MERGE_STATE.json").exists(), (
381 "MERGE_STATE.json must be cleared once the merge is finalized"
382 )
383
384 # The repo must not be stuck in a dangling merge-in-progress state.
385 r = _run_ok(repo, "status", "--json")
386 status_data = json.loads(r.output)
387 assert status_data["merge_in_progress"] is False, (
388 f"repo must not be stuck mid-merge after a successful finalize: {status_data}"
389 )
390
391
392 class TestDryRunNoMutation:
393 """muse merge --dry-run must not mutate any state."""
394
395 def test_dry_run_mutates_nothing(self, tmp_path: pathlib.Path) -> None:
396 repo = make_conflict_repo(tmp_path)
397 head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text()
398 calc_before = (repo / "calc.py").read_text()
399
400 # Dry-run on a genuinely conflicting merge correctly reports exit 1
401 # (a predicted conflict) — it must still mutate nothing.
402 r = _run(repo, "merge", "feat", "--strategy", "recursive", "--dry-run", "--json")
403 assert r.exit_code == 1, r.output
404 import json
405 data = json.loads(r.output)
406 assert data["status"] == "conflict"
407 assert data["dry_run"] is True
408
409 assert not (repo / ".muse" / "MERGE_STATE.json").exists(), (
410 "--dry-run must not write MERGE_STATE.json"
411 )
412 head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text()
413 assert head_after == head_before, "--dry-run must not move the branch ref"
414 calc_after = (repo / "calc.py").read_text()
415 assert calc_after == calc_before, "--dry-run must not touch the working tree"
416
417 r = _run_ok(repo, "reflog", "--json")
418 reflog_data = json.loads(r.output)
419 operations = [e.get("operation", "") for e in reflog_data.get("entries", [])]
420 assert not any("merge" in op.lower() for op in operations), (
421 "--dry-run must not write a reflog entry"
422 )
423
424
425 class TestHarmonyLearnsFromResolution:
426 """Harmony autoupdate (on by default) must learn from a resolved conflict."""
427
428 def test_harmony_records_resolution_after_manual_resolve(
429 self, tmp_path: pathlib.Path
430 ) -> None:
431 import json
432 repo = make_conflict_repo(tmp_path)
433 _run(repo, "merge", "feat", "--strategy", "recursive", "--json")
434
435 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n")
436 _run_ok(repo, "resolve", "calc.py::add", "--json")
437 _run_ok(repo, "commit", "-m", "resolve", "--json")
438
439 r = _run_ok(repo, "harmony", "list", "--json")
440 patterns = json.loads(r.output)["patterns"]
441 assert len(patterns) == 1
442 assert patterns[0]["resolution_count"] == 1
443
444
445 class TestHistoryModeSurvivesConflictCompletion:
446 """muse#29 fix: --history squash/rebase must not be silently dropped
447 when the merge pauses for a real conflict."""
448
449 @pytest.mark.parametrize("history", ["squash", "rebase"])
450 def test_history_mode_honoured_after_manual_resolve(
451 self, tmp_path: pathlib.Path, history: str
452 ) -> None:
453 import json
454 repo = make_conflict_repo(tmp_path)
455 _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json")
456
457 (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n")
458 _run_ok(repo, "resolve", "calc.py::add", "--json")
459 r = _run_ok(repo, "commit", "-m", "resolve", "--json")
460 data = json.loads(r.output)
461
462 assert data["parent2_commit_id"] is None, (
463 f"--history {history} must produce a single-parent commit even "
464 f"after a conflict pause, got parent2={data['parent2_commit_id']!r}"
465 )
File History 1 commit
sha256:26088198afa1ef5bf63a4b37349344742d40762144235e27ef26f3e7d9856cae test: comprehensive merge strategy x history x conflict-lev… Sonnet 5 minor ⚠ 1 day ago