gabriel / muse public
test_phase8_explain.py python
521 lines 24.3 KB
Raw
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47 feat(merge): add --explain flag with per-path decision trac… Sonnet 4.6 minor ⚠ breaking 1 day ago
1 """Phase 8 — muse merge --explain (issue #86).
2
3 EX_01 --explain flag accepted; no error, merge behavior unchanged.
4 EX_02 --explain --dry-run --json produces an 'explain' key in the JSON envelope.
5 EX_03 explain.strategy_routing contains requested_strategy, resolved_diff_unit,
6 resolved_resolution, case.
7 EX_04 explain.per_path entry for a conflicting file has correct fields.
8 EX_05 explain.per_path entry for a theirs-only change is decision='take_theirs_only'.
9 EX_06 explain.per_path entry for a convergent edit is decision='convergent'.
10 EX_07 explain.per_path entry for an untouched file is decision='no_change',
11 harmony_checked=false.
12 EX_08 After Harmony has a pattern, re-merging --explain shows
13 harmony_result='auto_resolved' and harmony_pattern_id is set.
14 EX_09 explain.summary counts match the actual per_path entries.
15 EX_10 --explain without --json emits human-readable text containing merge base,
16 strategy line, and per-changed-path lines.
17 """
18 from __future__ import annotations
19
20 import datetime
21 import json
22 import pathlib
23
24 import pytest
25
26 from muse.core.types import blob_id, fake_id
27 from muse.core.object_store import write_object
28 from muse.core.paths import heads_dir, muse_dir, ref_path
29 from tests.cli_test_helper import CliRunner
30
31 runner = CliRunner()
32
33
34 # ─────────────────────────────────────────────────────────────────────────────
35 # Helpers (mirror test_cmd_merge.py conventions)
36 # ─────────────────────────────────────────────────────────────────────────────
37
38 def _env(root: pathlib.Path) -> dict:
39 return {"MUSE_REPO_ROOT": str(root)}
40
41
42 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
43 dot_muse = muse_dir(tmp_path)
44 dot_muse.mkdir()
45 repo_id = fake_id("repo")
46 (dot_muse / "repo.json").write_text(json.dumps({
47 "repo_id": repo_id,
48 "domain": "code",
49 "default_branch": "main",
50 "created_at": "2025-01-01T00:00:00+00:00",
51 }), encoding="utf-8")
52 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
53 (dot_muse / "refs" / "heads").mkdir(parents=True)
54 (dot_muse / "snapshots").mkdir()
55 (dot_muse / "commits").mkdir()
56 (dot_muse / "objects").mkdir()
57 return tmp_path, repo_id
58
59
60 def _write_blob(root: pathlib.Path, content: bytes) -> str:
61 oid = blob_id(content)
62 write_object(root, oid, content)
63 return oid
64
65
66 def _make_commit(
67 root: pathlib.Path,
68 repo_id: str,
69 branch: str = "main",
70 message: str = "test",
71 manifest: dict | None = None,
72 ) -> str:
73 from muse.core.commits import CommitRecord, write_commit
74 from muse.core.snapshots import SnapshotRecord, write_snapshot
75 from muse.core.ids import hash_snapshot, hash_commit
76
77 ref_file = ref_path(root, branch)
78 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
79 m = manifest or {}
80 snap_id = hash_snapshot(m)
81 committed_at = datetime.datetime.now(datetime.timezone.utc)
82 commit_id = hash_commit(
83 parent_ids=[parent_id] if parent_id else [],
84 snapshot_id=snap_id,
85 message=message,
86 committed_at_iso=committed_at.isoformat(),
87 )
88 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
89 write_commit(root, CommitRecord(
90 commit_id=commit_id, branch=branch,
91 snapshot_id=snap_id, message=message, committed_at=committed_at,
92 parent_commit_id=parent_id,
93 ))
94 ref_file.parent.mkdir(parents=True, exist_ok=True)
95 ref_file.write_text(commit_id, encoding="utf-8")
96 return commit_id
97
98
99 def _setup_merge_scenario(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
100 """Create a three-commit repo with base → (ours, theirs) divergence.
101
102 Layout:
103 base: {unchanged.py: B0, conflict.py: B1, convergent.py: B2, theirs_only.py: absent}
104 ours: {unchanged.py: B0, conflict.py: O1, convergent.py: C0, theirs_only.py: absent}
105 theirs: {unchanged.py: B0, conflict.py: T1, convergent.py: C0, theirs_only.py: T3}
106
107 Where:
108 - unchanged.py: B0 on both sides → no_change
109 - conflict.py: O1 vs T1 (both different from base) → conflict
110 - convergent.py: C0 == C0 (both arrived at the same new content) → convergent
111 - theirs_only.py: absent in ours, added in theirs → take_theirs_only
112
113 Returns (root, base_commit_id, ours_commit_id, theirs_commit_id).
114 """
115 root, repo_id = _init_repo(tmp_path)
116
117 b0 = _write_blob(root, b"unchanged content\n")
118 b1 = _write_blob(root, b"conflict base\n")
119 b2 = _write_blob(root, b"convergent base\n")
120 o1 = _write_blob(root, b"conflict ours version\n")
121 t1 = _write_blob(root, b"conflict theirs version\n")
122 c0 = _write_blob(root, b"convergent final\n") # same on both sides
123 t3 = _write_blob(root, b"theirs only file\n")
124
125 base_manifest = {"unchanged.py": b0, "conflict.py": b1, "convergent.py": b2}
126 base_id = _make_commit(root, repo_id, branch="main", message="base", manifest=base_manifest)
127
128 ours_manifest = {"unchanged.py": b0, "conflict.py": o1, "convergent.py": c0}
129 ours_id = _make_commit(root, repo_id, branch="main", message="ours", manifest=ours_manifest)
130
131 # theirs branch starts from base
132 (heads_dir(root) / "feature").write_text(base_id, encoding="utf-8")
133 theirs_manifest = {
134 "unchanged.py": b0, "conflict.py": t1,
135 "convergent.py": c0, "theirs_only.py": t3,
136 }
137 theirs_id = _make_commit(root, repo_id, branch="feature", message="theirs", manifest=theirs_manifest)
138
139 return root, base_id, ours_id, theirs_id
140
141
142 # ─────────────────────────────────────────────────────────────────────────────
143 # EX_01 — --explain flag accepted; no error, behavior unchanged
144 # ─────────────────────────────────────────────────────────────────────────────
145
146 def test_EX_01_explain_flag_accepted(tmp_path):
147 """EX_01: --explain flag is accepted by argparse without error."""
148 import argparse
149 from muse.cli.commands.merge import register
150
151 p = argparse.ArgumentParser()
152 sub = p.add_subparsers()
153 register(sub)
154 ns = p.parse_args(["merge", "--explain", "feature"])
155 assert getattr(ns, "explain", None) is True, (
156 "EX_01: --explain flag must set explain=True on the parsed namespace"
157 )
158
159
160 # ─────────────────────────────────────────────────────────────────────────────
161 # EX_02 — --explain --dry-run --json produces 'explain' key
162 # ─────────────────────────────────────────────────────────────────────────────
163
164 def test_EX_02_explain_dry_run_json_has_explain_key(tmp_path):
165 """EX_02: --explain --dry-run --json embeds an 'explain' key in the envelope."""
166 root, _base, _ours, _theirs = _setup_merge_scenario(tmp_path)
167
168 result = runner.invoke(
169 None,
170 ["merge", "feature", "--explain", "--dry-run", "--json"],
171 env=_env(root),
172 catch_exceptions=False,
173 )
174 # Conflict exit code (1) is expected here — the scenario has a conflict.
175 # We only care that the JSON contains 'explain', not that the merge was clean.
176 assert result.exit_code in (0, 1), (
177 f"EX_02: unexpected exit code {result.exit_code}\n{result.output}"
178 )
179 assert result.output.strip(), f"EX_02: no output produced"
180 data = json.loads(result.output)
181 assert "explain" in data, (
182 f"EX_02: 'explain' key missing from --explain --dry-run --json output; "
183 f"got keys: {list(data.keys())}"
184 )
185
186
187 # ─────────────────────────────────────────────────────────────────────────────
188 # EX_03 — explain.strategy_routing fields
189 # ─────────────────────────────────────────────────────────────────────────────
190
191 def test_EX_03_strategy_routing_fields(tmp_path):
192 """EX_03: explain.strategy_routing contains all required fields."""
193 root, *_ = _setup_merge_scenario(tmp_path)
194
195 result = runner.invoke(
196 None,
197 ["merge", "feature", "--explain", "--dry-run", "--json"],
198 env=_env(root),
199 catch_exceptions=False,
200 )
201 assert result.exit_code in (0, 1), (
202 f"EX_03: unexpected exit code {result.exit_code}\n{result.output}"
203 )
204 assert result.output.strip(), f"EX_03: no output produced"
205 data = json.loads(result.output)
206 sr = data["explain"]["strategy_routing"]
207
208 assert "requested_strategy" in sr, "EX_03: requested_strategy missing"
209 assert "resolved_diff_unit" in sr, "EX_03: resolved_diff_unit missing"
210 assert "resolved_resolution" in sr, "EX_03: resolved_resolution missing"
211 assert "case" in sr, "EX_03: case missing"
212
213 # Default strategy is recursive → three_way + escalate
214 assert sr["resolved_diff_unit"] == "three_way", (
215 f"EX_03: expected resolved_diff_unit='three_way', got {sr['resolved_diff_unit']!r}"
216 )
217 assert sr["resolved_resolution"] == "escalate", (
218 f"EX_03: expected resolved_resolution='escalate', got {sr['resolved_resolution']!r}"
219 )
220
221
222 # ─────────────────────────────────────────────────────────────────────────────
223 # EX_04 — per_path entry for a conflicting file
224 # ─────────────────────────────────────────────────────────────────────────────
225
226 def test_EX_04_conflict_per_path_entry(tmp_path):
227 """EX_04: per_path entry for a conflicting file has decision='conflict'."""
228 root, *_ = _setup_merge_scenario(tmp_path)
229
230 result = runner.invoke(
231 None,
232 ["merge", "feature", "--explain", "--dry-run", "--json"],
233 env=_env(root),
234 catch_exceptions=False,
235 )
236 assert result.exit_code in (0, 1), (
237 f"EX_04: unexpected exit code {result.exit_code}\n{result.output}"
238 )
239 assert result.output.strip(), f"EX_04: no output produced"
240 data = json.loads(result.output)
241 per_path = {e["path"]: e for e in data["explain"]["per_path"]}
242
243 assert "conflict.py" in per_path, "EX_04: conflict.py not in per_path"
244 entry = per_path["conflict.py"]
245 assert entry["decision"] == "conflict", (
246 f"EX_04: expected decision='conflict', got {entry['decision']!r}"
247 )
248 assert entry["ours_changed"] is True, "EX_04: ours_changed must be True for conflict"
249 assert entry["theirs_changed"] is True, "EX_04: theirs_changed must be True for conflict"
250 assert entry.get("ours_id") != entry.get("theirs_id"), (
251 "EX_04: ours_id must differ from theirs_id for a conflict"
252 )
253
254
255 # ─────────────────────────────────────────────────────────────────────────────
256 # EX_05 — per_path entry for a theirs-only change
257 # ─────────────────────────────────────────────────────────────────────────────
258
259 def test_EX_05_theirs_only_per_path_entry(tmp_path):
260 """EX_05: per_path entry for a theirs-only change has decision='take_theirs_only'."""
261 root, *_ = _setup_merge_scenario(tmp_path)
262
263 result = runner.invoke(
264 None,
265 ["merge", "feature", "--explain", "--dry-run", "--json"],
266 env=_env(root),
267 catch_exceptions=False,
268 )
269 assert result.exit_code in (0, 1), (
270 f"EX_05: unexpected exit code {result.exit_code}\n{result.output}"
271 )
272 assert result.output.strip(), f"EX_05: no output produced"
273 data = json.loads(result.output)
274 per_path = {e["path"]: e for e in data["explain"]["per_path"]}
275
276 assert "theirs_only.py" in per_path, "EX_05: theirs_only.py not in per_path"
277 entry = per_path["theirs_only.py"]
278 assert entry["decision"] == "take_theirs_only", (
279 f"EX_05: expected decision='take_theirs_only', got {entry['decision']!r}"
280 )
281 assert entry["ours_changed"] is False, "EX_05: ours_changed must be False"
282 assert entry["theirs_changed"] is True, "EX_05: theirs_changed must be True"
283
284
285 # ─────────────────────────────────────────────────────────────────────────────
286 # EX_06 — per_path entry for a convergent edit
287 # ─────────────────────────────────────────────────────────────────────────────
288
289 def test_EX_06_convergent_per_path_entry(tmp_path):
290 """EX_06: per_path entry for a convergent edit has decision='convergent'."""
291 root, *_ = _setup_merge_scenario(tmp_path)
292
293 result = runner.invoke(
294 None,
295 ["merge", "feature", "--explain", "--dry-run", "--json"],
296 env=_env(root),
297 catch_exceptions=False,
298 )
299 assert result.exit_code in (0, 1), (
300 f"EX_06: unexpected exit code {result.exit_code}\n{result.output}"
301 )
302 assert result.output.strip(), f"EX_06: no output produced"
303 data = json.loads(result.output)
304 per_path = {e["path"]: e for e in data["explain"]["per_path"]}
305
306 assert "convergent.py" in per_path, "EX_06: convergent.py not in per_path"
307 entry = per_path["convergent.py"]
308 assert entry["decision"] == "convergent", (
309 f"EX_06: expected decision='convergent', got {entry['decision']!r}"
310 )
311 assert entry["ours_changed"] is True, "EX_06: ours_changed must be True (both changed)"
312 assert entry["theirs_changed"] is True, "EX_06: theirs_changed must be True (both changed)"
313 assert entry.get("ours_id") == entry.get("theirs_id"), (
314 "EX_06: ours_id must equal theirs_id for convergent edit"
315 )
316
317
318 # ─────────────────────────────────────────────────────────────────────────────
319 # EX_07 — per_path entry for an untouched file
320 # ─────────────────────────────────────────────────────────────────────────────
321
322 def test_EX_07_untouched_per_path_entry(tmp_path):
323 """EX_07: per_path entry for an untouched file has decision='no_change', harmony_checked=False."""
324 root, *_ = _setup_merge_scenario(tmp_path)
325
326 result = runner.invoke(
327 None,
328 ["merge", "feature", "--explain", "--dry-run", "--json"],
329 env=_env(root),
330 catch_exceptions=False,
331 )
332 assert result.exit_code in (0, 1), (
333 f"EX_07: unexpected exit code {result.exit_code}\n{result.output}"
334 )
335 assert result.output.strip(), f"EX_07: no output produced"
336 data = json.loads(result.output)
337 per_path = {e["path"]: e for e in data["explain"]["per_path"]}
338
339 assert "unchanged.py" in per_path, "EX_07: unchanged.py not in per_path"
340 entry = per_path["unchanged.py"]
341 assert entry["decision"] == "no_change", (
342 f"EX_07: expected decision='no_change', got {entry['decision']!r}"
343 )
344 assert entry.get("harmony_checked") is False, (
345 "EX_07: harmony_checked must be False for no_change entries"
346 )
347
348
349 # ─────────────────────────────────────────────────────────────────────────────
350 # EX_08 — harmony auto-resolved path shows harmony_result and pattern_id
351 # ─────────────────────────────────────────────────────────────────────────────
352
353 def test_EX_08_harmony_auto_resolved_in_explain(tmp_path):
354 """EX_08: After harmony has a pattern, --explain shows harmony_result='auto_resolved'."""
355 import datetime as _dt
356 from muse.core.harmony.types import AgentProvenance, ConflictPattern, ConflictType, Resolution, ResolutionStrategy
357 from muse.core.harmony.fingerprint import blob_fingerprint, compute_pattern_id, compute_resolution_id
358 from muse.core.harmony.patterns import record_pattern
359 from muse.core.harmony.resolutions import save_resolution
360 from muse.core.harmony.engine import compute_semantic_fingerprint
361 from muse.core.commits import read_commit
362 from muse.core.snapshots import read_snapshot
363 from muse.core.refs import get_head_commit_id, resolve_any_ref
364 from muse.core.object_store import write_object as _wo
365 from muse.plugins.registry import resolve_plugin
366
367 root, _base, _ours, _theirs = _setup_merge_scenario(tmp_path)
368
369 # Retrieve ours_id and theirs_id for conflict.py from the committed snapshots.
370 ours_cid = get_head_commit_id(root, "main")
371 theirs_cid = resolve_any_ref(root, "feature")
372 ours_snap = read_snapshot(root, read_commit(root, ours_cid).snapshot_id)
373 theirs_snap = read_snapshot(root, read_commit(root, theirs_cid).snapshot_id)
374
375 ours_id = ours_snap.manifest["conflict.py"]
376 theirs_id = theirs_snap.manifest["conflict.py"]
377
378 # Plant a harmony resolution for conflict.py at confidence=1.0.
379 outcome_content = b"conflict resolved content\n"
380 outcome_id = blob_id(outcome_content)
381 _wo(root, outcome_id, outcome_content)
382
383 blob_fp = blob_fingerprint(ours_id, theirs_id)
384 plugin = resolve_plugin(root)
385 semantic_fp = compute_semantic_fingerprint("conflict.py", ours_id, theirs_id, plugin, root)
386 pattern_id = compute_pattern_id("conflict.py", blob_fp, semantic_fp)
387
388 now = _dt.datetime.now(_dt.timezone.utc)
389 pattern = ConflictPattern(
390 pattern_id=pattern_id,
391 path="conflict.py",
392 domain="code",
393 conflict_type=ConflictType.CONTENT,
394 blob_fingerprint=blob_fp,
395 semantic_fingerprint=semantic_fp,
396 ours_id=ours_id,
397 theirs_id=theirs_id,
398 description={},
399 recorded_at=now,
400 recorded_by="test",
401 )
402 record_pattern(root, pattern)
403
404 by = AgentProvenance.human()
405 resolution_id = compute_resolution_id(
406 pattern_id, outcome_id, ResolutionStrategy.MANUAL, by, now
407 )
408 resolution = Resolution(
409 resolution_id=resolution_id,
410 pattern_id=pattern_id,
411 strategy=ResolutionStrategy.MANUAL,
412 policy_id=None,
413 outcome_blob=outcome_id,
414 resolved_by=by,
415 human_verified=True,
416 confidence=1.0,
417 rationale="test resolution",
418 resolved_at=now,
419 )
420 save_resolution(root, resolution)
421
422 # Run explain merge (live, not --dry-run) so harmony auto_apply fires.
423 # --force bypasses the dirty-workdir guard (tracked files absent from disk).
424 result = runner.invoke(
425 None,
426 ["merge", "feature", "--explain", "--json", "--force"],
427 env=_env(root),
428 catch_exceptions=False,
429 )
430 # Harmony should auto-resolve conflict.py → clean merge, exit 0.
431 assert result.exit_code == 0, (
432 f"EX_08: expected clean merge after harmony auto-resolve; "
433 f"exit={result.exit_code}\nstdout={result.output}\nstderr={result.stderr}"
434 )
435
436 data = json.loads(result.output)
437 per_path = {e["path"]: e for e in data["explain"]["per_path"]}
438
439 assert "conflict.py" in per_path, "EX_08: conflict.py not in per_path"
440 entry = per_path["conflict.py"]
441 assert entry.get("harmony_result") == "auto_resolved", (
442 f"EX_08: expected harmony_result='auto_resolved', got {entry.get('harmony_result')!r}"
443 )
444 assert entry.get("harmony_pattern_id") == pattern_id, (
445 f"EX_08: expected harmony_pattern_id={pattern_id!r}, "
446 f"got {entry.get('harmony_pattern_id')!r}"
447 )
448
449
450 # ─────────────────────────────────────────────────────────────────────────────
451 # EX_09 — explain.summary counts match per_path entries
452 # ─────────────────────────────────────────────────────────────────────────────
453
454 def test_EX_09_summary_counts_match_per_path(tmp_path):
455 """EX_09: explain.summary counts are correct and consistent with per_path."""
456 root, *_ = _setup_merge_scenario(tmp_path)
457
458 result = runner.invoke(
459 None,
460 ["merge", "feature", "--explain", "--dry-run", "--json"],
461 env=_env(root),
462 catch_exceptions=False,
463 )
464 assert result.exit_code in (0, 1), (
465 f"EX_09: unexpected exit code {result.exit_code}\n{result.output}"
466 )
467 assert result.output.strip(), f"EX_09: no output produced"
468 data = json.loads(result.output)
469 explain = data["explain"]
470 per_path = explain["per_path"]
471 summary = explain["summary"]
472
473 # Tally per_path by decision.
474 from collections import Counter
475 counts = Counter(e["decision"] for e in per_path)
476
477 assert summary["total_paths"] == len(per_path), (
478 f"EX_09: summary.total_paths={summary['total_paths']} != len(per_path)={len(per_path)}"
479 )
480 assert summary["conflicts"] == counts.get("conflict", 0), (
481 f"EX_09: summary.conflicts={summary['conflicts']} != per_path count {counts.get('conflict', 0)}"
482 )
483 assert summary["convergent"] == counts.get("convergent", 0), (
484 f"EX_09: summary.convergent={summary['convergent']} != per_path count {counts.get('convergent', 0)}"
485 )
486 assert summary["clean_no_change"] == counts.get("no_change", 0), (
487 f"EX_09: summary.clean_no_change={summary['clean_no_change']} != per_path count {counts.get('no_change', 0)}"
488 )
489
490
491 # ─────────────────────────────────────────────────────────────────────────────
492 # EX_10 — --explain without --json emits human-readable text
493 # ─────────────────────────────────────────────────────────────────────────────
494
495 def test_EX_10_human_readable_explain_output(tmp_path):
496 """EX_10: --explain without --json prints a merge base line, strategy, and per-path lines."""
497 root, *_ = _setup_merge_scenario(tmp_path)
498
499 # Use --dry-run to avoid writing commits.
500 result = runner.invoke(
501 None,
502 ["merge", "feature", "--explain", "--dry-run"],
503 env=_env(root),
504 catch_exceptions=False,
505 )
506 assert result.exit_code in (0, 1), (
507 f"EX_10: unexpected exit code {result.exit_code}\n{result.output}"
508 )
509
510 output = result.output
511 assert "sha256:" in output, (
512 "EX_10: human-readable output must contain the merge base commit ID (sha256:...)"
513 )
514 # Strategy line e.g. "Strategy: recursive ..."
515 assert "recursive" in output.lower() or "three_way" in output.lower(), (
516 "EX_10: human-readable output must name the strategy"
517 )
518 # At minimum, the conflicting file should appear
519 assert "conflict.py" in output, (
520 "EX_10: human-readable output must include a line for conflict.py"
521 )
File History 1 commit
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47 feat(merge): add --explain flag with per-path decision trac… Sonnet 4.6 minor 1 day ago