gabriel / muse public
test_phase1_merge_engine.py python
533 lines 21.6 KB
Raw
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb feat: add merge_result sub-object to muse merge --json (del… Sonnet 4.6 16 hours ago
1 """TDD tests for Phase 1 — MergeEngine extraction and CLI flag parity.
2
3 Issue #86 Phase 1 deliverables:
4 - MergeEngine class with diff_unit + resolution axes
5 - --strategy choices: recursive, overlay, snapshot, replay, ours, theirs
6 - --on-conflict flag: escalate | ours | theirs
7 - --history flag: merge | squash | rebase
8 - strategy aliases: --strategy ours == --strategy recursive --on-conflict ours
9
10 All tests must be RED before implementation, GREEN after.
11 """
12 from __future__ import annotations
13
14 import datetime
15 import json
16 import pathlib
17
18 import pytest
19 from tests.cli_test_helper import CliRunner
20 from muse.core.types import blob_id, fake_id
21 from muse.core.object_store import write_object, read_object
22 from muse.core.paths import heads_dir, muse_dir, ref_path
23
24 runner = CliRunner()
25
26
27 # ---------------------------------------------------------------------------
28 # Shared test helpers (mirrors test_phantom_conflicts.py)
29 # ---------------------------------------------------------------------------
30
31 def _env(root: pathlib.Path) -> dict:
32 return {"MUSE_REPO_ROOT": str(root)}
33
34
35 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
36 dot_muse = muse_dir(tmp_path)
37 dot_muse.mkdir()
38 repo_id = fake_id("repo")
39 (dot_muse / "repo.json").write_text(json.dumps({
40 "repo_id": repo_id,
41 "domain": "code",
42 "default_branch": "main",
43 "created_at": "2025-01-01T00:00:00+00:00",
44 }), encoding="utf-8")
45 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
46 (dot_muse / "refs" / "heads").mkdir(parents=True)
47 (dot_muse / "snapshots").mkdir()
48 (dot_muse / "commits").mkdir()
49 (dot_muse / "objects").mkdir()
50 return tmp_path, repo_id
51
52
53 def _write_obj(root: pathlib.Path, content: bytes) -> str:
54 oid = blob_id(content)
55 write_object(root, oid, content)
56 return oid
57
58
59 def _make_commit(
60 root: pathlib.Path,
61 repo_id: str,
62 branch: str = "main",
63 message: str = "test",
64 manifest: dict | None = None,
65 parent_id: str | None = None,
66 ) -> str:
67 from muse.core.commits import CommitRecord, write_commit
68 from muse.core.snapshots import SnapshotRecord, write_snapshot
69 from muse.core.ids import hash_snapshot, hash_commit
70
71 ref_file = ref_path(root, branch)
72 if parent_id is None:
73 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
74 m = manifest or {}
75 snap_id = hash_snapshot(m)
76 committed_at = datetime.datetime.now(datetime.timezone.utc)
77 commit_id = hash_commit(
78 parent_ids=[parent_id] if parent_id else [],
79 snapshot_id=snap_id,
80 message=message,
81 committed_at_iso=committed_at.isoformat(),
82 )
83 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
84 write_commit(root, CommitRecord(
85 commit_id=commit_id,
86 branch=branch,
87 snapshot_id=snap_id,
88 message=message,
89 committed_at=committed_at,
90 parent_commit_id=parent_id,
91 ))
92 ref_file.parent.mkdir(parents=True, exist_ok=True)
93 ref_file.write_text(commit_id, encoding="utf-8")
94 return commit_id
95
96
97 def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None:
98 """Set HEAD to branch and write manifest files to disk."""
99 (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8")
100 for path, oid in manifest.items():
101 content = read_object(root, oid)
102 if content is not None:
103 dest = root / path
104 dest.parent.mkdir(parents=True, exist_ok=True)
105 dest.write_bytes(content)
106
107
108 # ---------------------------------------------------------------------------
109 # Group 1 — MergeEngine class contract
110 # ---------------------------------------------------------------------------
111
112 class TestMergeEngineClass:
113 """MergeEngine must exist as an importable class with the right interface."""
114
115 def test_ME_01_importable(self) -> None:
116 """MergeEngine is importable from muse.core.merge_engine."""
117 from muse.core.merge_engine import MergeEngine # noqa: F401
118
119 def test_ME_02_valid_construction(self) -> None:
120 """MergeEngine accepts valid diff_unit and resolution values."""
121 from muse.core.merge_engine import MergeEngine
122 engine = MergeEngine(diff_unit="three_way", resolution="escalate")
123 assert engine.diff_unit == "three_way"
124 assert engine.resolution == "escalate"
125
126 def test_ME_03_all_diff_units_accepted(self) -> None:
127 """All four diff_unit values are valid."""
128 from muse.core.merge_engine import MergeEngine
129 for unit in ("three_way", "snapshot", "replay_ours", "replay_theirs"):
130 e = MergeEngine(diff_unit=unit, resolution="escalate")
131 assert e.diff_unit == unit
132
133 def test_ME_04_all_resolutions_accepted(self) -> None:
134 """All three resolution values are valid."""
135 from muse.core.merge_engine import MergeEngine
136 for res in ("escalate", "prefer_ours", "prefer_theirs"):
137 e = MergeEngine(diff_unit="three_way", resolution=res)
138 assert e.resolution == res
139
140 def test_ME_05_invalid_diff_unit_raises(self) -> None:
141 """Invalid diff_unit raises ValueError."""
142 from muse.core.merge_engine import MergeEngine
143 with pytest.raises((ValueError, TypeError)):
144 MergeEngine(diff_unit="git_merge", resolution="escalate")
145
146 def test_ME_06_invalid_resolution_raises(self) -> None:
147 """Invalid resolution raises ValueError."""
148 from muse.core.merge_engine import MergeEngine
149 with pytest.raises((ValueError, TypeError)):
150 MergeEngine(diff_unit="three_way", resolution="ask_nicely")
151
152 def test_ME_07_strategy_lookup_table_exists(self) -> None:
153 """STRATEGY_MAP maps named strategies to MergeEngine instances."""
154 from muse.core.merge_engine import STRATEGY_MAP
155 assert "recursive" in STRATEGY_MAP
156 assert "overlay" in STRATEGY_MAP
157 assert "snapshot" in STRATEGY_MAP
158 assert "replay" in STRATEGY_MAP
159
160 def test_ME_08_recursive_is_three_way_escalate(self) -> None:
161 """recursive == three_way + escalate."""
162 from muse.core.merge_engine import STRATEGY_MAP
163 e = STRATEGY_MAP["recursive"]
164 assert e.diff_unit == "three_way"
165 assert e.resolution == "escalate"
166
167 def test_ME_09_overlay_is_snapshot_prefer_theirs(self) -> None:
168 """overlay == snapshot + prefer_theirs."""
169 from muse.core.merge_engine import STRATEGY_MAP
170 e = STRATEGY_MAP["overlay"]
171 assert e.diff_unit == "snapshot"
172 assert e.resolution == "prefer_theirs"
173
174 def test_ME_10_snapshot_is_snapshot_escalate(self) -> None:
175 """snapshot == snapshot + escalate."""
176 from muse.core.merge_engine import STRATEGY_MAP
177 e = STRATEGY_MAP["snapshot"]
178 assert e.diff_unit == "snapshot"
179 assert e.resolution == "escalate"
180
181 def test_ME_11_replay_is_replay_ours_escalate(self) -> None:
182 """replay == replay_ours + escalate."""
183 from muse.core.merge_engine import STRATEGY_MAP
184 e = STRATEGY_MAP["replay"]
185 assert e.diff_unit == "replay_ours"
186 assert e.resolution == "escalate"
187
188 def test_ME_12_old_names_not_in_strategy_map(self) -> None:
189 """state_merge and state_replay are removed — not valid strategy names."""
190 from muse.core.merge_engine import STRATEGY_MAP
191 assert "state_merge" not in STRATEGY_MAP
192 assert "state_replay" not in STRATEGY_MAP
193
194
195 # ---------------------------------------------------------------------------
196 # Group 2 — --strategy CLI flag
197 # ---------------------------------------------------------------------------
198
199 class TestStrategyFlag:
200 """--strategy must accept the new vocabulary and reject the old."""
201
202 def _two_branch_repo(self, tmp_path: pathlib.Path):
203 root, repo_id = _init_repo(tmp_path)
204 a_oid = _write_obj(root, b"file_a base")
205 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid})
206
207 b_oid = _write_obj(root, b"file_a ours")
208 ours_id = _make_commit(root, repo_id, "main", "ours change",
209 {"a.py": b_oid}, parent_id=base_id)
210
211 feat_oid = _write_obj(root, b"file_b theirs")
212 feat_id = _make_commit(root, repo_id, "feat", "feat commit",
213 {"a.py": a_oid, "b.py": feat_oid}, parent_id=base_id)
214
215 _checkout(root, "main", {"a.py": b_oid})
216 return root, repo_id
217
218 def test_ST_01_strategy_recursive_accepted(self, tmp_path: pathlib.Path) -> None:
219 root, _ = self._two_branch_repo(tmp_path)
220 result = runner.invoke(
221 ["merge", "feat", "--strategy", "recursive", "--dry-run", "--json"],
222 env=_env(root),
223 )
224 data = json.loads(result.output)
225 assert data.get("exit_code") == 0
226
227 def test_ST_02_strategy_overlay_accepted(self, tmp_path: pathlib.Path) -> None:
228 root, _ = self._two_branch_repo(tmp_path)
229 result = runner.invoke(
230 ["merge", "feat", "--strategy", "overlay", "--dry-run", "--json"],
231 env=_env(root),
232 )
233 data = json.loads(result.output)
234 assert data.get("exit_code") == 0
235
236 def test_ST_03_strategy_snapshot_accepted(self, tmp_path: pathlib.Path) -> None:
237 """snapshot is the new name for state_merge."""
238 root, _ = self._two_branch_repo(tmp_path)
239 result = runner.invoke(
240 ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"],
241 env=_env(root),
242 )
243 data = json.loads(result.output)
244 assert data.get("exit_code") == 0
245
246 def test_ST_04_strategy_replay_accepted(self, tmp_path: pathlib.Path) -> None:
247 """replay is the new name for state_replay."""
248 root, _ = self._two_branch_repo(tmp_path)
249 result = runner.invoke(
250 ["merge", "feat", "--strategy", "replay", "--dry-run", "--json"],
251 env=_env(root),
252 )
253 data = json.loads(result.output)
254 assert data.get("exit_code") == 0
255
256 def test_ST_05_strategy_ours_accepted(self, tmp_path: pathlib.Path) -> None:
257 root, _ = self._two_branch_repo(tmp_path)
258 result = runner.invoke(
259 ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"],
260 env=_env(root),
261 )
262 data = json.loads(result.output)
263 assert data.get("exit_code") == 0
264
265 def test_ST_06_strategy_theirs_accepted(self, tmp_path: pathlib.Path) -> None:
266 root, _ = self._two_branch_repo(tmp_path)
267 result = runner.invoke(
268 ["merge", "feat", "--strategy", "theirs", "--dry-run", "--json"],
269 env=_env(root),
270 )
271 data = json.loads(result.output)
272 assert data.get("exit_code") == 0
273
274 def test_ST_07_state_merge_rejected(self, tmp_path: pathlib.Path) -> None:
275 """state_merge is the old name — must be rejected."""
276 root, _ = self._two_branch_repo(tmp_path)
277 result = runner.invoke(
278 ["merge", "feat", "--strategy", "state_merge", "--dry-run", "--json"],
279 env=_env(root),
280 )
281 assert result.exit_code != 0
282
283 def test_ST_08_state_replay_rejected(self, tmp_path: pathlib.Path) -> None:
284 """state_replay is the old name — must be rejected."""
285 root, _ = self._two_branch_repo(tmp_path)
286 result = runner.invoke(
287 ["merge", "feat", "--strategy", "state_replay", "--dry-run", "--json"],
288 env=_env(root),
289 )
290 assert result.exit_code != 0
291
292 def test_ST_09_json_output_includes_strategy(self, tmp_path: pathlib.Path) -> None:
293 """JSON output includes the strategy that was used."""
294 root, _ = self._two_branch_repo(tmp_path)
295 result = runner.invoke(
296 ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"],
297 env=_env(root),
298 )
299 data = json.loads(result.output)
300 assert data.get("strategy") == "snapshot"
301
302
303 # ---------------------------------------------------------------------------
304 # Group 3 — --on-conflict flag
305 # ---------------------------------------------------------------------------
306
307 class TestOnConflictFlag:
308 """--on-conflict must be accepted by muse merge and wired into JSON output."""
309
310 def _conflicting_repo(self, tmp_path: pathlib.Path):
311 """Two branches that genuinely conflict on a.py."""
312 root, repo_id = _init_repo(tmp_path)
313 a_base = _write_obj(root, b"line1\nline2\n")
314 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_base})
315
316 a_ours = _write_obj(root, b"line1\nOURS\n")
317 _make_commit(root, repo_id, "main", "ours", {"a.py": a_ours}, parent_id=base_id)
318
319 a_theirs = _write_obj(root, b"line1\nTHEIRS\n")
320 _make_commit(root, repo_id, "feat", "theirs", {"a.py": a_theirs}, parent_id=base_id)
321
322 _checkout(root, "main", {"a.py": a_ours})
323 return root, repo_id
324
325 def test_OC_01_on_conflict_flag_exists(self, tmp_path: pathlib.Path) -> None:
326 """--on-conflict is a recognised flag (no argparse error)."""
327 root, _ = self._conflicting_repo(tmp_path)
328 result = runner.invoke(
329 ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
330 env=_env(root),
331 )
332 # argparse unknown-flag produces exit_code 2; anything else means flag was parsed
333 assert result.exit_code != 2
334
335 def test_OC_02_on_conflict_escalate_accepted(self, tmp_path: pathlib.Path) -> None:
336 root, _ = self._conflicting_repo(tmp_path)
337 result = runner.invoke(
338 ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
339 env=_env(root),
340 )
341 assert result.exit_code != 2
342
343 def test_OC_03_on_conflict_ours_accepted(self, tmp_path: pathlib.Path) -> None:
344 root, _ = self._conflicting_repo(tmp_path)
345 result = runner.invoke(
346 ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
347 env=_env(root),
348 )
349 assert result.exit_code != 2
350
351 def test_OC_04_on_conflict_theirs_accepted(self, tmp_path: pathlib.Path) -> None:
352 root, _ = self._conflicting_repo(tmp_path)
353 result = runner.invoke(
354 ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"],
355 env=_env(root),
356 )
357 assert result.exit_code != 2
358
359 def test_OC_05_on_conflict_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
360 root, _ = self._conflicting_repo(tmp_path)
361 result = runner.invoke(
362 ["merge", "feat", "--on-conflict", "random_value", "--dry-run", "--json"],
363 env=_env(root),
364 )
365 assert result.exit_code != 0
366
367 def test_OC_06_on_conflict_ours_resolves_conflict(self, tmp_path: pathlib.Path) -> None:
368 """--on-conflict ours on a conflicting merge produces no conflicts in JSON."""
369 root, _ = self._conflicting_repo(tmp_path)
370 result = runner.invoke(
371 ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
372 env=_env(root),
373 )
374 data = json.loads(result.output)
375 assert data.get("conflicts") == [] or data.get("conflict_count") == 0
376
377 def test_OC_07_on_conflict_theirs_resolves_conflict(self, tmp_path: pathlib.Path) -> None:
378 """--on-conflict theirs on a conflicting merge produces no conflicts in JSON."""
379 root, _ = self._conflicting_repo(tmp_path)
380 result = runner.invoke(
381 ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"],
382 env=_env(root),
383 )
384 data = json.loads(result.output)
385 assert data.get("conflicts") == [] or data.get("conflict_count") == 0
386
387 def test_OC_08_escalate_surfaces_conflict(self, tmp_path: pathlib.Path) -> None:
388 """--on-conflict escalate (default) surfaces conflicts normally."""
389 root, _ = self._conflicting_repo(tmp_path)
390 result = runner.invoke(
391 ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
392 env=_env(root),
393 )
394 data = json.loads(result.output)
395 conflicts = data.get("conflicts", [])
396 conflict_count = data.get("conflict_count", len(conflicts))
397 assert conflict_count > 0
398
399 def test_OC_09_strategy_ours_alias_equals_on_conflict_ours(self, tmp_path: pathlib.Path) -> None:
400 """--strategy ours and --strategy recursive --on-conflict ours produce identical results."""
401 root, repo_id = self._conflicting_repo(tmp_path)
402
403 result_alias = runner.invoke(
404 ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"],
405 env=_env(root),
406 )
407 result_explicit = runner.invoke(
408 ["merge", "feat", "--strategy", "recursive", "--on-conflict", "ours", "--dry-run", "--json"],
409 env=_env(root),
410 )
411 alias_data = json.loads(result_alias.output)
412 explicit_data = json.loads(result_explicit.output)
413 assert alias_data.get("conflicts") == explicit_data.get("conflicts")
414
415 def test_OC_10_json_output_includes_on_conflict(self, tmp_path: pathlib.Path) -> None:
416 """JSON output includes the on_conflict value that was used."""
417 root, _ = self._conflicting_repo(tmp_path)
418 result = runner.invoke(
419 ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
420 env=_env(root),
421 )
422 data = json.loads(result.output)
423 assert data.get("on_conflict") == "ours"
424
425
426 # ---------------------------------------------------------------------------
427 # Group 4 — --history flag
428 # ---------------------------------------------------------------------------
429
430 class TestHistoryFlag:
431 """--history must be accepted by muse merge."""
432
433 def _clean_merge_repo(self, tmp_path: pathlib.Path):
434 """Two branches with no conflicts."""
435 root, repo_id = _init_repo(tmp_path)
436 a_oid = _write_obj(root, b"file_a base")
437 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid})
438
439 b_oid = _write_obj(root, b"file_b ours")
440 _make_commit(root, repo_id, "main", "ours", {"a.py": a_oid, "b.py": b_oid},
441 parent_id=base_id)
442
443 c_oid = _write_obj(root, b"file_c theirs")
444 _make_commit(root, repo_id, "feat", "feat", {"a.py": a_oid, "c.py": c_oid},
445 parent_id=base_id)
446
447 _checkout(root, "main", {"a.py": a_oid, "b.py": b_oid})
448 return root, repo_id
449
450 def test_HI_01_history_flag_exists(self, tmp_path: pathlib.Path) -> None:
451 """--history is a recognised flag (no argparse exit_code 2)."""
452 root, _ = self._clean_merge_repo(tmp_path)
453 result = runner.invoke(
454 ["merge", "feat", "--history", "merge", "--dry-run", "--json"],
455 env=_env(root),
456 )
457 assert result.exit_code != 2
458
459 def test_HI_02_history_merge_accepted(self, tmp_path: pathlib.Path) -> None:
460 root, _ = self._clean_merge_repo(tmp_path)
461 result = runner.invoke(
462 ["merge", "feat", "--history", "merge", "--dry-run", "--json"],
463 env=_env(root),
464 )
465 assert result.exit_code != 2
466
467 def test_HI_03_history_squash_accepted(self, tmp_path: pathlib.Path) -> None:
468 root, _ = self._clean_merge_repo(tmp_path)
469 result = runner.invoke(
470 ["merge", "feat", "--history", "squash", "--dry-run", "--json"],
471 env=_env(root),
472 )
473 assert result.exit_code != 2
474
475 def test_HI_04_history_rebase_accepted(self, tmp_path: pathlib.Path) -> None:
476 root, _ = self._clean_merge_repo(tmp_path)
477 result = runner.invoke(
478 ["merge", "feat", "--history", "rebase", "--dry-run", "--json"],
479 env=_env(root),
480 )
481 assert result.exit_code != 2
482
483 def test_HI_05_history_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
484 root, _ = self._clean_merge_repo(tmp_path)
485 result = runner.invoke(
486 ["merge", "feat", "--history", "squash_rebase_both", "--dry-run", "--json"],
487 env=_env(root),
488 )
489 assert result.exit_code != 0
490
491 def test_HI_06_history_merge_produces_two_parent_commit(self, tmp_path: pathlib.Path) -> None:
492 """--history merge produces a commit with two parents."""
493 root, _ = self._clean_merge_repo(tmp_path)
494 result = runner.invoke(
495 ["merge", "feat", "--history", "merge", "--json"],
496 env=_env(root),
497 )
498 data = json.loads(result.output)
499 assert data.get("exit_code") == 0
500 commit_id = data.get("commit_id")
501 assert commit_id is not None
502
503 from muse.core.commits import read_commit
504 rec = read_commit(root, commit_id)
505 assert rec is not None
506 assert rec.parent2_commit_id is not None
507
508 def test_HI_07_history_squash_produces_single_parent_commit(self, tmp_path: pathlib.Path) -> None:
509 """--history squash produces a commit with one parent (no merge commit)."""
510 root, _ = self._clean_merge_repo(tmp_path)
511 result = runner.invoke(
512 ["merge", "feat", "--history", "squash", "--json"],
513 env=_env(root),
514 )
515 data = json.loads(result.output)
516 assert data.get("exit_code") == 0
517 commit_id = data.get("commit_id")
518 assert commit_id is not None
519
520 from muse.core.commits import read_commit
521 rec = read_commit(root, commit_id)
522 assert rec is not None
523 assert rec.parent2_commit_id is None
524
525 def test_HI_08_json_output_includes_history(self, tmp_path: pathlib.Path) -> None:
526 """JSON output includes the history mode that was used."""
527 root, _ = self._clean_merge_repo(tmp_path)
528 result = runner.invoke(
529 ["merge", "feat", "--history", "squash", "--dry-run", "--json"],
530 env=_env(root),
531 )
532 data = json.loads(result.output)
533 assert data.get("history") == "squash"
File History 3 commits
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb feat: add merge_result sub-object to muse merge --json (del… Sonnet 4.6 16 hours ago
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1 feat: Phase 1 — MergeEngine class, --on-conflict, --history… Sonnet 4.6 patch 16 hours ago
sha256:e7283cd82f2f9cef3de581b97fd8ac849509ccc66861b6c81680144ffcd166c3 test: Phase 1 TDD — MergeEngine class, --strategy, --on-con… Sonnet 4.6 17 hours ago