"""Phase 8 — muse merge --explain (issue #86). EX_01 --explain flag accepted; no error, merge behavior unchanged. EX_02 --explain --dry-run --json produces an 'explain' key in the JSON envelope. EX_03 explain.strategy_routing contains requested_strategy, resolved_diff_unit, resolved_resolution, case. EX_04 explain.per_path entry for a conflicting file has correct fields. EX_05 explain.per_path entry for a theirs-only change is decision='take_theirs_only'. EX_06 explain.per_path entry for a convergent edit is decision='convergent'. EX_07 explain.per_path entry for an untouched file is decision='no_change', harmony_checked=false. EX_08 After Harmony has a pattern, re-merging --explain shows harmony_result='auto_resolved' and harmony_pattern_id is set. EX_09 explain.summary counts match the actual per_path entries. EX_10 --explain without --json emits human-readable text containing merge base, strategy line, and per-changed-path lines. """ from __future__ import annotations import datetime import json import pathlib import pytest from muse.core.types import blob_id, fake_id from muse.core.object_store import write_object from muse.core.paths import heads_dir, muse_dir, ref_path from tests.cli_test_helper import CliRunner runner = CliRunner() # ───────────────────────────────────────────────────────────────────────────── # Helpers (mirror test_cmd_merge.py conventions) # ───────────────────────────────────────────────────────────────────────────── def _env(root: pathlib.Path) -> dict: return {"MUSE_REPO_ROOT": str(root)} def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() repo_id = fake_id("repo") (dot_muse / "repo.json").write_text(json.dumps({ "repo_id": repo_id, "domain": "code", "default_branch": "main", "created_at": "2025-01-01T00:00:00+00:00", }), encoding="utf-8") (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "snapshots").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "objects").mkdir() return tmp_path, repo_id def _write_blob(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid def _make_commit( root: pathlib.Path, repo_id: str, branch: str = "main", message: str = "test", manifest: dict | None = None, ) -> str: from muse.core.commits import CommitRecord, write_commit from muse.core.snapshots import SnapshotRecord, write_snapshot from muse.core.ids import hash_snapshot, hash_commit ref_file = ref_path(root, branch) parent_id = ref_file.read_text().strip() if ref_file.exists() else None m = manifest or {} snap_id = hash_snapshot(m) committed_at = datetime.datetime.now(datetime.timezone.utc) commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [], snapshot_id=snap_id, message=message, committed_at_iso=committed_at.isoformat(), ) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m)) write_commit(root, CommitRecord( commit_id=commit_id, branch=branch, snapshot_id=snap_id, message=message, committed_at=committed_at, parent_commit_id=parent_id, )) ref_file.parent.mkdir(parents=True, exist_ok=True) ref_file.write_text(commit_id, encoding="utf-8") return commit_id def _setup_merge_scenario(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]: """Create a three-commit repo with base → (ours, theirs) divergence. Layout: base: {unchanged.py: B0, conflict.py: B1, convergent.py: B2, theirs_only.py: absent} ours: {unchanged.py: B0, conflict.py: O1, convergent.py: C0, theirs_only.py: absent} theirs: {unchanged.py: B0, conflict.py: T1, convergent.py: C0, theirs_only.py: T3} Where: - unchanged.py: B0 on both sides → no_change - conflict.py: O1 vs T1 (both different from base) → conflict - convergent.py: C0 == C0 (both arrived at the same new content) → convergent - theirs_only.py: absent in ours, added in theirs → take_theirs_only Returns (root, base_commit_id, ours_commit_id, theirs_commit_id). """ root, repo_id = _init_repo(tmp_path) b0 = _write_blob(root, b"unchanged content\n") b1 = _write_blob(root, b"conflict base\n") b2 = _write_blob(root, b"convergent base\n") o1 = _write_blob(root, b"conflict ours version\n") t1 = _write_blob(root, b"conflict theirs version\n") c0 = _write_blob(root, b"convergent final\n") # same on both sides t3 = _write_blob(root, b"theirs only file\n") base_manifest = {"unchanged.py": b0, "conflict.py": b1, "convergent.py": b2} base_id = _make_commit(root, repo_id, branch="main", message="base", manifest=base_manifest) ours_manifest = {"unchanged.py": b0, "conflict.py": o1, "convergent.py": c0} ours_id = _make_commit(root, repo_id, branch="main", message="ours", manifest=ours_manifest) # theirs branch starts from base (heads_dir(root) / "feature").write_text(base_id, encoding="utf-8") theirs_manifest = { "unchanged.py": b0, "conflict.py": t1, "convergent.py": c0, "theirs_only.py": t3, } theirs_id = _make_commit(root, repo_id, branch="feature", message="theirs", manifest=theirs_manifest) return root, base_id, ours_id, theirs_id # ───────────────────────────────────────────────────────────────────────────── # EX_01 — --explain flag accepted; no error, behavior unchanged # ───────────────────────────────────────────────────────────────────────────── def test_EX_01_explain_flag_accepted(tmp_path): """EX_01: --explain flag is accepted by argparse without error.""" import argparse from muse.cli.commands.merge import register p = argparse.ArgumentParser() sub = p.add_subparsers() register(sub) ns = p.parse_args(["merge", "--explain", "feature"]) assert getattr(ns, "explain", None) is True, ( "EX_01: --explain flag must set explain=True on the parsed namespace" ) # ───────────────────────────────────────────────────────────────────────────── # EX_02 — --explain --dry-run --json produces 'explain' key # ───────────────────────────────────────────────────────────────────────────── def test_EX_02_explain_dry_run_json_has_explain_key(tmp_path): """EX_02: --explain --dry-run --json embeds an 'explain' key in the envelope.""" root, _base, _ours, _theirs = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) # Conflict exit code (1) is expected here — the scenario has a conflict. # We only care that the JSON contains 'explain', not that the merge was clean. assert result.exit_code in (0, 1), ( f"EX_02: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_02: no output produced" data = json.loads(result.output) assert "explain" in data, ( f"EX_02: 'explain' key missing from --explain --dry-run --json output; " f"got keys: {list(data.keys())}" ) # ───────────────────────────────────────────────────────────────────────────── # EX_03 — explain.strategy_routing fields # ───────────────────────────────────────────────────────────────────────────── def test_EX_03_strategy_routing_fields(tmp_path): """EX_03: explain.strategy_routing contains all required fields.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_03: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_03: no output produced" data = json.loads(result.output) sr = data["explain"]["strategy_routing"] assert "requested_strategy" in sr, "EX_03: requested_strategy missing" assert "resolved_diff_unit" in sr, "EX_03: resolved_diff_unit missing" assert "resolved_resolution" in sr, "EX_03: resolved_resolution missing" assert "case" in sr, "EX_03: case missing" # Default strategy is recursive → three_way + escalate assert sr["resolved_diff_unit"] == "three_way", ( f"EX_03: expected resolved_diff_unit='three_way', got {sr['resolved_diff_unit']!r}" ) assert sr["resolved_resolution"] == "escalate", ( f"EX_03: expected resolved_resolution='escalate', got {sr['resolved_resolution']!r}" ) # ───────────────────────────────────────────────────────────────────────────── # EX_04 — per_path entry for a conflicting file # ───────────────────────────────────────────────────────────────────────────── def test_EX_04_conflict_per_path_entry(tmp_path): """EX_04: per_path entry for a conflicting file has decision='conflict'.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_04: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_04: no output produced" data = json.loads(result.output) per_path = {e["path"]: e for e in data["explain"]["per_path"]} assert "conflict.py" in per_path, "EX_04: conflict.py not in per_path" entry = per_path["conflict.py"] assert entry["decision"] == "conflict", ( f"EX_04: expected decision='conflict', got {entry['decision']!r}" ) assert entry["ours_changed"] is True, "EX_04: ours_changed must be True for conflict" assert entry["theirs_changed"] is True, "EX_04: theirs_changed must be True for conflict" assert entry.get("ours_id") != entry.get("theirs_id"), ( "EX_04: ours_id must differ from theirs_id for a conflict" ) # ───────────────────────────────────────────────────────────────────────────── # EX_05 — per_path entry for a theirs-only change # ───────────────────────────────────────────────────────────────────────────── def test_EX_05_theirs_only_per_path_entry(tmp_path): """EX_05: per_path entry for a theirs-only change has decision='take_theirs_only'.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_05: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_05: no output produced" data = json.loads(result.output) per_path = {e["path"]: e for e in data["explain"]["per_path"]} assert "theirs_only.py" in per_path, "EX_05: theirs_only.py not in per_path" entry = per_path["theirs_only.py"] assert entry["decision"] == "take_theirs_only", ( f"EX_05: expected decision='take_theirs_only', got {entry['decision']!r}" ) assert entry["ours_changed"] is False, "EX_05: ours_changed must be False" assert entry["theirs_changed"] is True, "EX_05: theirs_changed must be True" # ───────────────────────────────────────────────────────────────────────────── # EX_06 — per_path entry for a convergent edit # ───────────────────────────────────────────────────────────────────────────── def test_EX_06_convergent_per_path_entry(tmp_path): """EX_06: per_path entry for a convergent edit has decision='convergent'.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_06: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_06: no output produced" data = json.loads(result.output) per_path = {e["path"]: e for e in data["explain"]["per_path"]} assert "convergent.py" in per_path, "EX_06: convergent.py not in per_path" entry = per_path["convergent.py"] assert entry["decision"] == "convergent", ( f"EX_06: expected decision='convergent', got {entry['decision']!r}" ) assert entry["ours_changed"] is True, "EX_06: ours_changed must be True (both changed)" assert entry["theirs_changed"] is True, "EX_06: theirs_changed must be True (both changed)" assert entry.get("ours_id") == entry.get("theirs_id"), ( "EX_06: ours_id must equal theirs_id for convergent edit" ) # ───────────────────────────────────────────────────────────────────────────── # EX_07 — per_path entry for an untouched file # ───────────────────────────────────────────────────────────────────────────── def test_EX_07_untouched_per_path_entry(tmp_path): """EX_07: per_path entry for an untouched file has decision='no_change', harmony_checked=False.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_07: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_07: no output produced" data = json.loads(result.output) per_path = {e["path"]: e for e in data["explain"]["per_path"]} assert "unchanged.py" in per_path, "EX_07: unchanged.py not in per_path" entry = per_path["unchanged.py"] assert entry["decision"] == "no_change", ( f"EX_07: expected decision='no_change', got {entry['decision']!r}" ) assert entry.get("harmony_checked") is False, ( "EX_07: harmony_checked must be False for no_change entries" ) # ───────────────────────────────────────────────────────────────────────────── # EX_08 — harmony auto-resolved path shows harmony_result and pattern_id # ───────────────────────────────────────────────────────────────────────────── def test_EX_08_harmony_auto_resolved_in_explain(tmp_path): """EX_08: After harmony has a pattern, --explain shows harmony_result='auto_resolved'.""" import datetime as _dt from muse.core.harmony.types import AgentProvenance, ConflictPattern, ConflictType, Resolution, ResolutionStrategy from muse.core.harmony.fingerprint import blob_fingerprint, compute_pattern_id, compute_resolution_id from muse.core.harmony.patterns import record_pattern from muse.core.harmony.resolutions import save_resolution from muse.core.harmony.engine import compute_semantic_fingerprint from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot from muse.core.refs import get_head_commit_id, resolve_any_ref from muse.core.object_store import write_object as _wo from muse.plugins.registry import resolve_plugin root, _base, _ours, _theirs = _setup_merge_scenario(tmp_path) # Retrieve ours_id and theirs_id for conflict.py from the committed snapshots. ours_cid = get_head_commit_id(root, "main") theirs_cid = resolve_any_ref(root, "feature") ours_snap = read_snapshot(root, read_commit(root, ours_cid).snapshot_id) theirs_snap = read_snapshot(root, read_commit(root, theirs_cid).snapshot_id) ours_id = ours_snap.manifest["conflict.py"] theirs_id = theirs_snap.manifest["conflict.py"] # Plant a harmony resolution for conflict.py at confidence=1.0. outcome_content = b"conflict resolved content\n" outcome_id = blob_id(outcome_content) _wo(root, outcome_id, outcome_content) blob_fp = blob_fingerprint(ours_id, theirs_id) plugin = resolve_plugin(root) semantic_fp = compute_semantic_fingerprint("conflict.py", ours_id, theirs_id, plugin, root) pattern_id = compute_pattern_id("conflict.py", blob_fp, semantic_fp) now = _dt.datetime.now(_dt.timezone.utc) pattern = ConflictPattern( pattern_id=pattern_id, path="conflict.py", domain="code", conflict_type=ConflictType.CONTENT, blob_fingerprint=blob_fp, semantic_fingerprint=semantic_fp, ours_id=ours_id, theirs_id=theirs_id, description={}, recorded_at=now, recorded_by="test", ) record_pattern(root, pattern) by = AgentProvenance.human() resolution_id = compute_resolution_id( pattern_id, outcome_id, ResolutionStrategy.MANUAL, by, now ) resolution = Resolution( resolution_id=resolution_id, pattern_id=pattern_id, strategy=ResolutionStrategy.MANUAL, policy_id=None, outcome_blob=outcome_id, resolved_by=by, human_verified=True, confidence=1.0, rationale="test resolution", resolved_at=now, ) save_resolution(root, resolution) # Run explain merge (live, not --dry-run) so harmony auto_apply fires. # --force bypasses the dirty-workdir guard (tracked files absent from disk). result = runner.invoke( None, ["merge", "feature", "--explain", "--json", "--force"], env=_env(root), catch_exceptions=False, ) # Harmony should auto-resolve conflict.py → clean merge, exit 0. assert result.exit_code == 0, ( f"EX_08: expected clean merge after harmony auto-resolve; " f"exit={result.exit_code}\nstdout={result.output}\nstderr={result.stderr}" ) data = json.loads(result.output) per_path = {e["path"]: e for e in data["explain"]["per_path"]} assert "conflict.py" in per_path, "EX_08: conflict.py not in per_path" entry = per_path["conflict.py"] assert entry.get("harmony_result") == "auto_resolved", ( f"EX_08: expected harmony_result='auto_resolved', got {entry.get('harmony_result')!r}" ) assert entry.get("harmony_pattern_id") == pattern_id, ( f"EX_08: expected harmony_pattern_id={pattern_id!r}, " f"got {entry.get('harmony_pattern_id')!r}" ) # ───────────────────────────────────────────────────────────────────────────── # EX_09 — explain.summary counts match per_path entries # ───────────────────────────────────────────────────────────────────────────── def test_EX_09_summary_counts_match_per_path(tmp_path): """EX_09: explain.summary counts are correct and consistent with per_path.""" root, *_ = _setup_merge_scenario(tmp_path) result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run", "--json"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_09: unexpected exit code {result.exit_code}\n{result.output}" ) assert result.output.strip(), f"EX_09: no output produced" data = json.loads(result.output) explain = data["explain"] per_path = explain["per_path"] summary = explain["summary"] # Tally per_path by decision. from collections import Counter counts = Counter(e["decision"] for e in per_path) assert summary["total_paths"] == len(per_path), ( f"EX_09: summary.total_paths={summary['total_paths']} != len(per_path)={len(per_path)}" ) assert summary["conflicts"] == counts.get("conflict", 0), ( f"EX_09: summary.conflicts={summary['conflicts']} != per_path count {counts.get('conflict', 0)}" ) assert summary["convergent"] == counts.get("convergent", 0), ( f"EX_09: summary.convergent={summary['convergent']} != per_path count {counts.get('convergent', 0)}" ) assert summary["clean_no_change"] == counts.get("no_change", 0), ( f"EX_09: summary.clean_no_change={summary['clean_no_change']} != per_path count {counts.get('no_change', 0)}" ) # ───────────────────────────────────────────────────────────────────────────── # EX_10 — --explain without --json emits human-readable text # ───────────────────────────────────────────────────────────────────────────── def test_EX_10_human_readable_explain_output(tmp_path): """EX_10: --explain without --json prints a merge base line, strategy, and per-path lines.""" root, *_ = _setup_merge_scenario(tmp_path) # Use --dry-run to avoid writing commits. result = runner.invoke( None, ["merge", "feature", "--explain", "--dry-run"], env=_env(root), catch_exceptions=False, ) assert result.exit_code in (0, 1), ( f"EX_10: unexpected exit code {result.exit_code}\n{result.output}" ) output = result.output assert "sha256:" in output, ( "EX_10: human-readable output must contain the merge base commit ID (sha256:...)" ) # Strategy line e.g. "Strategy: recursive ..." assert "recursive" in output.lower() or "three_way" in output.lower(), ( "EX_10: human-readable output must name the strategy" ) # At minimum, the conflicting file should appear assert "conflict.py" in output, ( "EX_10: human-readable output must include a line for conflict.py" )