gabriel / muse public
test_phase1_merge_engine.py python
642 lines 27.1 KB
Raw
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb feat: add merge_result sub-object to muse merge --json (del… Sonnet 4.6 1 day 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 cli = None
26
27
28 # ---------------------------------------------------------------------------
29 # Shared test helpers (mirrors test_phantom_conflicts.py)
30 # ---------------------------------------------------------------------------
31
32 def _env(root: pathlib.Path) -> dict:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
37 dot_muse = muse_dir(tmp_path)
38 dot_muse.mkdir()
39 repo_id = fake_id("repo")
40 (dot_muse / "repo.json").write_text(json.dumps({
41 "repo_id": repo_id,
42 "domain": "code",
43 "default_branch": "main",
44 "created_at": "2025-01-01T00:00:00+00:00",
45 }), encoding="utf-8")
46 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
47 (dot_muse / "refs" / "heads").mkdir(parents=True)
48 (dot_muse / "snapshots").mkdir()
49 (dot_muse / "commits").mkdir()
50 (dot_muse / "objects").mkdir()
51 return tmp_path, repo_id
52
53
54 def _write_obj(root: pathlib.Path, content: bytes) -> str:
55 oid = blob_id(content)
56 write_object(root, oid, content)
57 return oid
58
59
60 def _make_commit(
61 root: pathlib.Path,
62 repo_id: str,
63 branch: str = "main",
64 message: str = "test",
65 manifest: dict | None = None,
66 parent_id: str | None = None,
67 ) -> str:
68 from muse.core.commits import CommitRecord, write_commit
69 from muse.core.snapshots import SnapshotRecord, write_snapshot
70 from muse.core.ids import hash_snapshot, hash_commit
71
72 ref_file = ref_path(root, branch)
73 if parent_id is None:
74 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
75 m = manifest or {}
76 snap_id = hash_snapshot(m)
77 committed_at = datetime.datetime.now(datetime.timezone.utc)
78 commit_id = hash_commit(
79 parent_ids=[parent_id] if parent_id else [],
80 snapshot_id=snap_id,
81 message=message,
82 committed_at_iso=committed_at.isoformat(),
83 )
84 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
85 write_commit(root, CommitRecord(
86 commit_id=commit_id,
87 branch=branch,
88 snapshot_id=snap_id,
89 message=message,
90 committed_at=committed_at,
91 parent_commit_id=parent_id,
92 ))
93 ref_file.parent.mkdir(parents=True, exist_ok=True)
94 ref_file.write_text(commit_id, encoding="utf-8")
95 return commit_id
96
97
98 def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None:
99 """Set HEAD to branch and write manifest files to disk."""
100 (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8")
101 for path, oid in manifest.items():
102 content = read_object(root, oid)
103 if content is not None:
104 dest = root / path
105 dest.parent.mkdir(parents=True, exist_ok=True)
106 dest.write_bytes(content)
107
108
109 # ---------------------------------------------------------------------------
110 # Group 1 — MergeEngine class contract
111 # ---------------------------------------------------------------------------
112
113 class TestMergeEngineClass:
114 """MergeEngine must exist as an importable class with the right interface."""
115
116 def test_ME_01_importable(self) -> None:
117 """MergeEngine is importable from muse.core.merge_engine."""
118 from muse.core.merge_engine import MergeEngine # noqa: F401
119
120 def test_ME_02_valid_construction(self) -> None:
121 """MergeEngine accepts valid diff_unit and resolution values."""
122 from muse.core.merge_engine import MergeEngine
123 engine = MergeEngine(diff_unit="three_way", resolution="escalate")
124 assert engine.diff_unit == "three_way"
125 assert engine.resolution == "escalate"
126
127 def test_ME_03_all_diff_units_accepted(self) -> None:
128 """All four diff_unit values are valid."""
129 from muse.core.merge_engine import MergeEngine
130 for unit in ("three_way", "snapshot", "replay_ours", "replay_theirs"):
131 e = MergeEngine(diff_unit=unit, resolution="escalate")
132 assert e.diff_unit == unit
133
134 def test_ME_04_all_resolutions_accepted(self) -> None:
135 """All three resolution values are valid."""
136 from muse.core.merge_engine import MergeEngine
137 for res in ("escalate", "prefer_ours", "prefer_theirs"):
138 e = MergeEngine(diff_unit="three_way", resolution=res)
139 assert e.resolution == res
140
141 def test_ME_05_invalid_diff_unit_raises(self) -> None:
142 """Invalid diff_unit raises ValueError."""
143 from muse.core.merge_engine import MergeEngine
144 with pytest.raises((ValueError, TypeError)):
145 MergeEngine(diff_unit="git_merge", resolution="escalate")
146
147 def test_ME_06_invalid_resolution_raises(self) -> None:
148 """Invalid resolution raises ValueError."""
149 from muse.core.merge_engine import MergeEngine
150 with pytest.raises((ValueError, TypeError)):
151 MergeEngine(diff_unit="three_way", resolution="ask_nicely")
152
153 def test_ME_07_strategy_lookup_table_exists(self) -> None:
154 """STRATEGY_MAP maps named strategies to MergeEngine instances."""
155 from muse.core.merge_engine import STRATEGY_MAP
156 assert "recursive" in STRATEGY_MAP
157 assert "overlay" in STRATEGY_MAP
158 assert "snapshot" in STRATEGY_MAP
159 assert "replay" in STRATEGY_MAP
160
161 def test_ME_08_recursive_is_three_way_escalate(self) -> None:
162 """recursive == three_way + escalate."""
163 from muse.core.merge_engine import STRATEGY_MAP
164 e = STRATEGY_MAP["recursive"]
165 assert e.diff_unit == "three_way"
166 assert e.resolution == "escalate"
167
168 def test_ME_09_overlay_is_snapshot_prefer_theirs(self) -> None:
169 """overlay == snapshot + prefer_theirs."""
170 from muse.core.merge_engine import STRATEGY_MAP
171 e = STRATEGY_MAP["overlay"]
172 assert e.diff_unit == "snapshot"
173 assert e.resolution == "prefer_theirs"
174
175 def test_ME_10_snapshot_is_snapshot_escalate(self) -> None:
176 """snapshot == snapshot + escalate."""
177 from muse.core.merge_engine import STRATEGY_MAP
178 e = STRATEGY_MAP["snapshot"]
179 assert e.diff_unit == "snapshot"
180 assert e.resolution == "escalate"
181
182 def test_ME_11_replay_is_replay_ours_escalate(self) -> None:
183 """replay == replay_ours + escalate."""
184 from muse.core.merge_engine import STRATEGY_MAP
185 e = STRATEGY_MAP["replay"]
186 assert e.diff_unit == "replay_ours"
187 assert e.resolution == "escalate"
188
189 def test_ME_12_old_names_not_in_strategy_map(self) -> None:
190 """state_merge and state_replay are removed — not valid strategy names."""
191 from muse.core.merge_engine import STRATEGY_MAP
192 assert "state_merge" not in STRATEGY_MAP
193 assert "state_replay" not in STRATEGY_MAP
194
195
196 # ---------------------------------------------------------------------------
197 # Group 2 — --strategy CLI flag
198 # ---------------------------------------------------------------------------
199
200 class TestStrategyFlag:
201 """--strategy must accept the new vocabulary and reject the old."""
202
203 def _two_branch_repo(self, tmp_path: pathlib.Path):
204 root, repo_id = _init_repo(tmp_path)
205 a_oid = _write_obj(root, b"file_a base")
206 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid})
207
208 b_oid = _write_obj(root, b"file_a ours")
209 ours_id = _make_commit(root, repo_id, "main", "ours change",
210 {"a.py": b_oid}, parent_id=base_id)
211
212 feat_oid = _write_obj(root, b"file_b theirs")
213 feat_id = _make_commit(root, repo_id, "feat", "feat commit",
214 {"a.py": a_oid, "b.py": feat_oid}, parent_id=base_id)
215
216 _checkout(root, "main", {"a.py": b_oid})
217 return root, repo_id
218
219 def test_ST_01_strategy_recursive_accepted(self, tmp_path: pathlib.Path) -> None:
220 root, _ = self._two_branch_repo(tmp_path)
221 result = runner.invoke(
222 cli, ["merge", "feat", "--strategy", "recursive", "--dry-run", "--json"],
223 env=_env(root),
224 )
225 data = json.loads(result.output)
226 assert data.get("exit_code") == 0
227
228 def test_ST_02_strategy_overlay_accepted(self, tmp_path: pathlib.Path) -> None:
229 root, _ = self._two_branch_repo(tmp_path)
230 result = runner.invoke(
231 cli, ["merge", "feat", "--strategy", "overlay", "--dry-run", "--json"],
232 env=_env(root),
233 )
234 data = json.loads(result.output)
235 assert data.get("exit_code") == 0
236
237 def test_ST_03_strategy_snapshot_accepted(self, tmp_path: pathlib.Path) -> None:
238 """snapshot is the new name for state_merge."""
239 root, _ = self._two_branch_repo(tmp_path)
240 result = runner.invoke(
241 cli, ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"],
242 env=_env(root),
243 )
244 data = json.loads(result.output)
245 assert data.get("exit_code") == 0
246
247 def test_ST_04_strategy_replay_accepted(self, tmp_path: pathlib.Path) -> None:
248 """replay is the new name for state_replay."""
249 root, _ = self._two_branch_repo(tmp_path)
250 result = runner.invoke(
251 cli, ["merge", "feat", "--strategy", "replay", "--dry-run", "--json"],
252 env=_env(root),
253 )
254 data = json.loads(result.output)
255 assert data.get("exit_code") == 0
256
257 def test_ST_05_strategy_ours_accepted(self, tmp_path: pathlib.Path) -> None:
258 root, _ = self._two_branch_repo(tmp_path)
259 result = runner.invoke(
260 cli, ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"],
261 env=_env(root),
262 )
263 data = json.loads(result.output)
264 assert data.get("exit_code") == 0
265
266 def test_ST_06_strategy_theirs_accepted(self, tmp_path: pathlib.Path) -> None:
267 root, _ = self._two_branch_repo(tmp_path)
268 result = runner.invoke(
269 cli, ["merge", "feat", "--strategy", "theirs", "--dry-run", "--json"],
270 env=_env(root),
271 )
272 data = json.loads(result.output)
273 assert data.get("exit_code") == 0
274
275 def test_ST_07_state_merge_rejected(self, tmp_path: pathlib.Path) -> None:
276 """state_merge is the old name — must be rejected."""
277 root, _ = self._two_branch_repo(tmp_path)
278 result = runner.invoke(
279 cli, ["merge", "feat", "--strategy", "state_merge", "--dry-run", "--json"],
280 env=_env(root),
281 )
282 assert result.exit_code != 0
283
284 def test_ST_08_state_replay_rejected(self, tmp_path: pathlib.Path) -> None:
285 """state_replay is the old name — must be rejected."""
286 root, _ = self._two_branch_repo(tmp_path)
287 result = runner.invoke(
288 cli, ["merge", "feat", "--strategy", "state_replay", "--dry-run", "--json"],
289 env=_env(root),
290 )
291 assert result.exit_code != 0
292
293 def test_ST_09_json_output_includes_strategy(self, tmp_path: pathlib.Path) -> None:
294 """JSON output includes the strategy that was used."""
295 root, _ = self._two_branch_repo(tmp_path)
296 result = runner.invoke(
297 cli, ["merge", "feat", "--strategy", "snapshot", "--dry-run", "--json"],
298 env=_env(root),
299 )
300 data = json.loads(result.output)
301 assert data.get("strategy") == "snapshot"
302
303
304 # ---------------------------------------------------------------------------
305 # Group 3 — --on-conflict flag
306 # ---------------------------------------------------------------------------
307
308 class TestOnConflictFlag:
309 """--on-conflict must be accepted by muse merge and wired into JSON output."""
310
311 def _conflicting_repo(self, tmp_path: pathlib.Path):
312 """Two branches that genuinely conflict on a.py."""
313 root, repo_id = _init_repo(tmp_path)
314 a_base = _write_obj(root, b"line1\nline2\n")
315 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_base})
316
317 a_ours = _write_obj(root, b"line1\nOURS\n")
318 _make_commit(root, repo_id, "main", "ours", {"a.py": a_ours}, parent_id=base_id)
319
320 a_theirs = _write_obj(root, b"line1\nTHEIRS\n")
321 _make_commit(root, repo_id, "feat", "theirs", {"a.py": a_theirs}, parent_id=base_id)
322
323 _checkout(root, "main", {"a.py": a_ours})
324 return root, repo_id
325
326 def test_OC_01_on_conflict_flag_exists(self, tmp_path: pathlib.Path) -> None:
327 """--on-conflict is a recognised flag (no argparse error)."""
328 root, _ = self._conflicting_repo(tmp_path)
329 result = runner.invoke(
330 cli, ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
331 env=_env(root),
332 )
333 # argparse unknown-flag produces exit_code 2; anything else means flag was parsed
334 assert result.exit_code != 2
335
336 def test_OC_02_on_conflict_escalate_accepted(self, tmp_path: pathlib.Path) -> None:
337 root, _ = self._conflicting_repo(tmp_path)
338 result = runner.invoke(
339 cli, ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
340 env=_env(root),
341 )
342 assert result.exit_code != 2
343
344 def test_OC_03_on_conflict_ours_accepted(self, tmp_path: pathlib.Path) -> None:
345 root, _ = self._conflicting_repo(tmp_path)
346 result = runner.invoke(
347 cli, ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
348 env=_env(root),
349 )
350 assert result.exit_code != 2
351
352 def test_OC_04_on_conflict_theirs_accepted(self, tmp_path: pathlib.Path) -> None:
353 root, _ = self._conflicting_repo(tmp_path)
354 result = runner.invoke(
355 cli, ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"],
356 env=_env(root),
357 )
358 assert result.exit_code != 2
359
360 def test_OC_05_on_conflict_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
361 root, _ = self._conflicting_repo(tmp_path)
362 result = runner.invoke(
363 cli, ["merge", "feat", "--on-conflict", "random_value", "--dry-run", "--json"],
364 env=_env(root),
365 )
366 assert result.exit_code != 0
367
368 def test_OC_06_on_conflict_ours_resolves_conflict(self, tmp_path: pathlib.Path) -> None:
369 """--on-conflict ours on a conflicting merge produces no conflicts in JSON."""
370 root, _ = self._conflicting_repo(tmp_path)
371 result = runner.invoke(
372 cli, ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
373 env=_env(root),
374 )
375 data = json.loads(result.output)
376 assert data.get("conflicts") == [] or data.get("conflict_count") == 0
377
378 def test_OC_07_on_conflict_theirs_resolves_conflict(self, tmp_path: pathlib.Path) -> None:
379 """--on-conflict theirs on a conflicting merge produces no conflicts in JSON."""
380 root, _ = self._conflicting_repo(tmp_path)
381 result = runner.invoke(
382 cli, ["merge", "feat", "--on-conflict", "theirs", "--dry-run", "--json"],
383 env=_env(root),
384 )
385 data = json.loads(result.output)
386 assert data.get("conflicts") == [] or data.get("conflict_count") == 0
387
388 def test_OC_08_escalate_surfaces_conflict(self, tmp_path: pathlib.Path) -> None:
389 """--on-conflict escalate (default) surfaces conflicts normally."""
390 root, _ = self._conflicting_repo(tmp_path)
391 result = runner.invoke(
392 cli, ["merge", "feat", "--on-conflict", "escalate", "--dry-run", "--json"],
393 env=_env(root),
394 )
395 data = json.loads(result.output)
396 conflicts = data.get("conflicts", [])
397 conflict_count = data.get("conflict_count", len(conflicts))
398 assert conflict_count > 0
399
400 def test_OC_09_strategy_ours_alias_equals_on_conflict_ours(self, tmp_path: pathlib.Path) -> None:
401 """--strategy ours and --strategy recursive --on-conflict ours produce identical results."""
402 root, repo_id = self._conflicting_repo(tmp_path)
403
404 result_alias = runner.invoke(
405 cli, ["merge", "feat", "--strategy", "ours", "--dry-run", "--json"],
406 env=_env(root),
407 )
408 result_explicit = runner.invoke(
409 cli, ["merge", "feat", "--strategy", "recursive", "--on-conflict", "ours", "--dry-run", "--json"],
410 env=_env(root),
411 )
412 alias_data = json.loads(result_alias.output)
413 explicit_data = json.loads(result_explicit.output)
414 assert alias_data.get("conflicts") == explicit_data.get("conflicts")
415
416 def test_OC_10_json_output_includes_on_conflict(self, tmp_path: pathlib.Path) -> None:
417 """JSON output includes the on_conflict value that was used."""
418 root, _ = self._conflicting_repo(tmp_path)
419 result = runner.invoke(
420 cli, ["merge", "feat", "--on-conflict", "ours", "--dry-run", "--json"],
421 env=_env(root),
422 )
423 data = json.loads(result.output)
424 assert data.get("on_conflict") == "ours"
425
426
427 # ---------------------------------------------------------------------------
428 # Group 4 — --history flag
429 # ---------------------------------------------------------------------------
430
431 class TestHistoryFlag:
432 """--history must be accepted by muse merge."""
433
434 def _clean_merge_repo(self, tmp_path: pathlib.Path):
435 """Two branches with no conflicts."""
436 root, repo_id = _init_repo(tmp_path)
437 a_oid = _write_obj(root, b"file_a base")
438 base_id = _make_commit(root, repo_id, "main", "base", {"a.py": a_oid})
439
440 b_oid = _write_obj(root, b"file_b ours")
441 _make_commit(root, repo_id, "main", "ours", {"a.py": a_oid, "b.py": b_oid},
442 parent_id=base_id)
443
444 c_oid = _write_obj(root, b"file_c theirs")
445 _make_commit(root, repo_id, "feat", "feat", {"a.py": a_oid, "c.py": c_oid},
446 parent_id=base_id)
447
448 _checkout(root, "main", {"a.py": a_oid, "b.py": b_oid})
449 return root, repo_id
450
451 def test_HI_01_history_flag_exists(self, tmp_path: pathlib.Path) -> None:
452 """--history is a recognised flag (no argparse exit_code 2)."""
453 root, _ = self._clean_merge_repo(tmp_path)
454 result = runner.invoke(
455 cli, ["merge", "feat", "--history", "merge", "--dry-run", "--json"],
456 env=_env(root),
457 )
458 assert result.exit_code != 2
459
460 def test_HI_02_history_merge_accepted(self, tmp_path: pathlib.Path) -> None:
461 root, _ = self._clean_merge_repo(tmp_path)
462 result = runner.invoke(
463 cli, ["merge", "feat", "--history", "merge", "--dry-run", "--json"],
464 env=_env(root),
465 )
466 assert result.exit_code != 2
467
468 def test_HI_03_history_squash_accepted(self, tmp_path: pathlib.Path) -> None:
469 root, _ = self._clean_merge_repo(tmp_path)
470 result = runner.invoke(
471 cli, ["merge", "feat", "--history", "squash", "--dry-run", "--json"],
472 env=_env(root),
473 )
474 assert result.exit_code != 2
475
476 def test_HI_04_history_rebase_accepted(self, tmp_path: pathlib.Path) -> None:
477 root, _ = self._clean_merge_repo(tmp_path)
478 result = runner.invoke(
479 cli, ["merge", "feat", "--history", "rebase", "--dry-run", "--json"],
480 env=_env(root),
481 )
482 assert result.exit_code != 2
483
484 def test_HI_05_history_invalid_rejected(self, tmp_path: pathlib.Path) -> None:
485 root, _ = self._clean_merge_repo(tmp_path)
486 result = runner.invoke(
487 cli, ["merge", "feat", "--history", "squash_rebase_both", "--dry-run", "--json"],
488 env=_env(root),
489 )
490 assert result.exit_code != 0
491
492 def test_HI_06_history_merge_produces_two_parent_commit(self, tmp_path: pathlib.Path) -> None:
493 """--history merge produces a commit with two parents."""
494 root, _ = self._clean_merge_repo(tmp_path)
495 result = runner.invoke(
496 cli, ["merge", "feat", "--history", "merge", "--json"],
497 env=_env(root),
498 )
499 data = json.loads(result.output)
500 assert data.get("exit_code") == 0
501 commit_id = data.get("commit_id")
502 assert commit_id is not None
503
504 from muse.core.commits import read_commit
505 rec = read_commit(root, commit_id)
506 assert rec is not None
507 assert rec.parent2_commit_id is not None
508
509 def test_HI_07_history_squash_produces_single_parent_commit(self, tmp_path: pathlib.Path) -> None:
510 """--history squash produces a commit with one parent (no merge commit)."""
511 root, _ = self._clean_merge_repo(tmp_path)
512 result = runner.invoke(
513 cli, ["merge", "feat", "--history", "squash", "--json"],
514 env=_env(root),
515 )
516 data = json.loads(result.output)
517 assert data.get("exit_code") == 0
518 commit_id = data.get("commit_id")
519 assert commit_id is not None
520
521 from muse.core.commits import read_commit
522 rec = read_commit(root, commit_id)
523 assert rec is not None
524 assert rec.parent2_commit_id is None
525
526 def test_HI_08_json_output_includes_history(self, tmp_path: pathlib.Path) -> None:
527 """JSON output includes the history mode that was used."""
528 root, _ = self._clean_merge_repo(tmp_path)
529 result = runner.invoke(
530 cli, ["merge", "feat", "--history", "squash", "--dry-run", "--json"],
531 env=_env(root),
532 )
533 data = json.loads(result.output)
534 assert data.get("history") == "squash"
535
536
537 # ---------------------------------------------------------------------------
538 # Group 5 — merge_result sub-object (deliverable 5: JSON parity)
539 # ---------------------------------------------------------------------------
540
541 class TestMergeResultShape:
542 """merge_result sub-object must be present in every muse merge --json output path.
543
544 Agents read data["merge_result"] on the local path and
545 data["mergeResult"] on the hub proposal path — same fields, same contract.
546 """
547
548 @staticmethod
549 def _clean_merge_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
550 root, repo_id = _init_repo(tmp_path)
551 a_id = _write_obj(root, b"file_a v1")
552 b_id = _write_obj(root, b"file_b v1")
553 base_id = _make_commit(root, repo_id, "main", "base",
554 {"file_a.py": a_id, "file_b.py": b_id})
555 b_v2 = _write_obj(root, b"file_b v2 feat only")
556 (muse_dir(root) / "refs" / "heads" / "feat").write_text(base_id, encoding="utf-8")
557 _make_commit(root, repo_id, "feat", "feat adds b",
558 {"file_a.py": a_id, "file_b.py": b_v2}, parent_id=base_id)
559 _checkout(root, "main", {"file_a.py": a_id, "file_b.py": b_id})
560 return root, repo_id
561
562 def test_MR_01_merge_result_present_on_clean_merge(self, tmp_path: pathlib.Path) -> None:
563 """merge_result key must exist in JSON for a clean (no-conflict) merge."""
564 root, _ = self._clean_merge_repo(tmp_path)
565 result = runner.invoke(cli, ["merge", "feat", "--json"],
566 env=_env(root), catch_exceptions=False)
567 data = json.loads(result.output)
568 assert "merge_result" in data, "merge_result key must be present on successful merge"
569
570 def test_MR_02_merge_result_fields(self, tmp_path: pathlib.Path) -> None:
571 """merge_result must carry status, commit_id, strategy, on_conflict, history, conflicts, files_changed, semver_impact."""
572 root, _ = self._clean_merge_repo(tmp_path)
573 result = runner.invoke(cli, ["merge", "feat", "--strategy", "recursive",
574 "--on-conflict", "escalate", "--history", "merge", "--json"],
575 env=_env(root), catch_exceptions=False)
576 data = json.loads(result.output)
577 mr = data.get("merge_result", {})
578 assert mr.get("status") == data.get("status")
579 assert mr.get("commit_id") == data.get("commit_id")
580 assert mr.get("strategy") == "recursive"
581 assert mr.get("on_conflict") == "escalate"
582 assert mr.get("history") == "merge"
583 assert isinstance(mr.get("conflicts"), list)
584 assert isinstance(mr.get("files_changed"), dict)
585 assert "semver_impact" in mr
586
587 def test_MR_03_merge_result_on_fast_forward(self, tmp_path: pathlib.Path) -> None:
588 """merge_result must be present on a fast-forward merge."""
589 root, _ = self._clean_merge_repo(tmp_path)
590 result = runner.invoke(cli, ["merge", "feat", "--json"],
591 env=_env(root), catch_exceptions=False)
592 data = json.loads(result.output)
593 assert "merge_result" in data
594 mr = data["merge_result"]
595 assert mr["status"] in ("merged", "fast_forward", "up_to_date")
596
597 def test_MR_04_merge_result_on_dry_run(self, tmp_path: pathlib.Path) -> None:
598 """merge_result must be present even on --dry-run."""
599 root, _ = self._clean_merge_repo(tmp_path)
600 result = runner.invoke(cli, ["merge", "feat", "--dry-run", "--json"],
601 env=_env(root), catch_exceptions=False)
602 data = json.loads(result.output)
603 assert "merge_result" in data
604 mr = data["merge_result"]
605 assert mr.get("commit_id") is None
606
607 def test_MR_05_merge_result_mirrors_top_level_files_changed(self, tmp_path: pathlib.Path) -> None:
608 """merge_result.files_changed must mirror the top-level files_changed."""
609 root, _ = self._clean_merge_repo(tmp_path)
610 result = runner.invoke(cli, ["merge", "feat", "--json"],
611 env=_env(root), catch_exceptions=False)
612 data = json.loads(result.output)
613 assert data["merge_result"]["files_changed"] == data["files_changed"]
614
615 def test_MR_06_musehub_model_has_merge_result_field(self) -> None:
616 """MergeResultEmbed is importable and ProposalResponse has merge_result field."""
617 from musehub.models.musehub import MergeResultEmbed, ProposalResponse
618 import pydantic
619 fields = ProposalResponse.model_fields
620 assert "merge_result" in fields
621 ann = fields["merge_result"].annotation
622 assert ann is not None
623
624 def test_MR_07_merge_result_embed_construction(self) -> None:
625 """MergeResultEmbed accepts expected fields and round-trips cleanly."""
626 from musehub.models.musehub import MergeResultEmbed
627 embed = MergeResultEmbed(
628 status="merged",
629 commit_id="sha256:abc",
630 strategy="recursive",
631 on_conflict=None,
632 history="merge",
633 conflicts=[],
634 files_changed={"added": 1, "modified": 0, "deleted": 0},
635 semver_impact="MINOR",
636 )
637 assert embed.status == "merged"
638 assert embed.commit_id == "sha256:abc"
639 assert embed.strategy == "recursive"
640 assert embed.history == "merge"
641 payload = embed.model_dump()
642 assert payload["files_changed"] == {"added": 1, "modified": 0, "deleted": 0}
File History 3 commits
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb feat: add merge_result sub-object to muse merge --json (del… Sonnet 4.6 1 day ago
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1 feat: Phase 1 — MergeEngine class, --on-conflict, --history… Sonnet 4.6 patch 1 day ago
sha256:e7283cd82f2f9cef3de581b97fd8ac849509ccc66861b6c81680144ffcd166c3 test: Phase 1 TDD — MergeEngine class, --strategy, --on-con… Sonnet 4.6 1 day ago