"""Phase 4 of issue #8: Harmony integration — manually_resolved tracking. Coverage -------- MERGE_STATE: - resolve_path records cleared entries in manually_resolved - resolve_symbol records the cleared entry in manually_resolved - manually_resolved accumulates across multiple resolve calls - manually_resolved is preserved through _write_conflict_paths round-trips - original_conflict_paths is never affected by resolve - read_merge_state round-trips manually_resolved correctly - legacy MERGE_STATE (no manually_resolved key) defaults to empty list record_resolutions: - manually_resolved=None → legacy: all paths get confidence=1.0, human_verified=True - manually_resolved=set() → tracking active, none manually resolved → confidence=0.8, human_verified=False - path IN manually_resolved → confidence=1.0, human_verified=True - path NOT IN manually_resolved → confidence=0.8, human_verified=False - mixed: some manual, some side-picked → each gets correct values - rationale text reflects manual vs side-picked End-to-end: - full flow: muse resolve → commit → harmony patterns have correct confidence - checkout --ours side-pick then commit → lower confidence when muse resolve also used """ from __future__ import annotations import json import os import pathlib import pytest from muse.core.harmony import ( list_patterns, list_resolutions, record_resolutions, ) from muse.core.merge_engine import ( MergeState, read_merge_state, resolve_path, resolve_symbol, write_merge_state, ) from muse.core.object_store import write_object from muse.core.paths import muse_dir from muse.core.types import MUSE_DIR, Manifest, blob_id, fake_id from tests.cli_test_helper import CliRunner runner = CliRunner() _BASE = fake_id("base") _OURS = fake_id("ours") _THEIRS = fake_id("theirs") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _repo(tmp_path: pathlib.Path) -> pathlib.Path: (tmp_path / MUSE_DIR).mkdir() return tmp_path def _harmony_repo(tmp_path: pathlib.Path) -> pathlib.Path: muse_dir(tmp_path).mkdir() return tmp_path def _write(root: pathlib.Path, conflicts: list[str]) -> None: write_merge_state( root, base_commit=_BASE, ours_commit=_OURS, theirs_commit=_THEIRS, conflict_paths=conflicts, other_branch="feat/x", ) def _state(root: pathlib.Path) -> MergeState: s = read_merge_state(root) assert s is not None return s def _write_object(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid class _FakePlugin: name = "test" def schema(self) -> "dict[str, object]": return {} def _invoke(repo: pathlib.Path, args: list[str]) -> "InvokeResult": # type: ignore[name-defined] saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) # --------------------------------------------------------------------------- # MERGE_STATE — manually_resolved tracking # --------------------------------------------------------------------------- class TestManuallyResolvedTracking: def test_resolve_path_records_in_manually_resolved( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["hello.md", "world.md"]) resolve_path(repo, "hello.md") assert _state(repo).manually_resolved == ["hello.md"] def test_resolve_path_symbol_entries_recorded( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["hello.md::A", "hello.md::B", "other.py"]) resolve_path(repo, "hello.md") mr = _state(repo).manually_resolved assert "hello.md::A" in mr assert "hello.md::B" in mr assert "other.py" not in mr def test_resolve_symbol_records_in_manually_resolved( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["hello.md::A", "hello.md::B"]) resolve_symbol(repo, "hello.md::A") assert "hello.md::A" in _state(repo).manually_resolved assert "hello.md::B" not in _state(repo).manually_resolved def test_manually_resolved_accumulates_across_calls( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["a.py", "b.py", "c.py"]) resolve_symbol(repo, "a.py") resolve_symbol(repo, "b.py") mr = _state(repo).manually_resolved assert "a.py" in mr assert "b.py" in mr assert "c.py" not in mr def test_resolve_path_noop_does_not_add_to_manually_resolved( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["other.py"]) resolve_path(repo, "hello.md") # not in conflict_paths assert _state(repo).manually_resolved == [] def test_resolve_symbol_noop_does_not_add_to_manually_resolved( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["other.py"]) resolve_symbol(repo, "hello.md") # not in conflict_paths assert _state(repo).manually_resolved == [] def test_manually_resolved_does_not_affect_original_conflict_paths( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["a.py", "b.py"]) original = _state(repo).original_conflict_paths[:] resolve_path(repo, "a.py") assert _state(repo).original_conflict_paths == original def test_read_write_round_trip_manually_resolved( self, tmp_path: pathlib.Path ) -> None: repo = _repo(tmp_path) _write(repo, ["a.py::X", "b.py"]) resolve_symbol(repo, "a.py::X") resolve_symbol(repo, "b.py") state = _state(repo) assert sorted(state.manually_resolved) == ["a.py::X", "b.py"] assert state.conflict_paths == [] assert sorted(state.original_conflict_paths) == ["a.py::X", "b.py"] def test_legacy_merge_state_no_manually_resolved_key( self, tmp_path: pathlib.Path ) -> None: """MERGE_STATE without manually_resolved key (pre-Phase-4) → empty list.""" repo = _repo(tmp_path) merge_state_path = repo / MUSE_DIR / "MERGE_STATE.json" merge_state_path.write_text(json.dumps({ "base_commit": _BASE, "ours_commit": _OURS, "theirs_commit": _THEIRS, "conflict_paths": ["hello.md"], "original_conflict_paths": ["hello.md"], }), encoding="utf-8") state = _state(repo) assert state.manually_resolved == [] # --------------------------------------------------------------------------- # record_resolutions — manually_resolved parameter # --------------------------------------------------------------------------- class TestRecordResolutionsManuallyResolved: def test_none_gives_legacy_human_verified_true( self, tmp_path: pathlib.Path ) -> None: """manually_resolved=None → legacy: confidence=1.0, human_verified=True.""" repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved=None) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert res[0].human_verified is True assert res[0].confidence == 1.0 def test_empty_set_gives_low_confidence( self, tmp_path: pathlib.Path ) -> None: """manually_resolved={} → tracking active, path not manually resolved → confidence=0.8.""" repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved=set()) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert res[0].human_verified is False assert res[0].confidence == 0.8 def test_path_in_manually_resolved_gets_high_confidence( self, tmp_path: pathlib.Path ) -> None: repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved={"f.py"}) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert res[0].human_verified is True assert res[0].confidence == 1.0 def test_path_not_in_manually_resolved_gets_low_confidence( self, tmp_path: pathlib.Path ) -> None: repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved={"other.py"}) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert res[0].human_verified is False assert res[0].confidence == 0.8 def test_mixed_paths_get_different_confidence( self, tmp_path: pathlib.Path ) -> None: """Two paths: one manually resolved, one side-picked → different confidence.""" repo = _harmony_repo(tmp_path) a_ours = _write_object(repo, b"a-ours") a_theirs = _write_object(repo, b"a-theirs") a_res = _write_object(repo, b"a-resolved") b_ours = _write_object(repo, b"b-ours") b_theirs = _write_object(repo, b"b-theirs") b_res = _write_object(repo, b"b-resolved") ours_m: Manifest = {"a.py": a_ours, "b.py": b_ours} theirs_m: Manifest = {"a.py": a_theirs, "b.py": b_theirs} new_m: Manifest = {"a.py": a_res, "b.py": b_res} plugin = _FakePlugin() record_resolutions( repo, ["a.py", "b.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved={"a.py"}, # only a.py was manually resolved ) patterns = list_patterns(repo) by_path = {p.path: list_resolutions(repo, p.pattern_id)[0] for p in patterns} assert by_path["a.py"].human_verified is True assert by_path["a.py"].confidence == 1.0 assert by_path["b.py"].human_verified is False assert by_path["b.py"].confidence == 0.8 def test_manual_rationale_text(self, tmp_path: pathlib.Path) -> None: repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved={"f.py"}) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert "muse resolve" in res[0].rationale def test_side_pick_rationale_text(self, tmp_path: pathlib.Path) -> None: repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions(repo, ["f.py"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved=set()) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert "checkout" in res[0].rationale def test_symbol_level_path_in_manually_resolved( self, tmp_path: pathlib.Path ) -> None: """Symbol-level paths (file::Symbol) are matched exactly in manually_resolved.""" repo = _harmony_repo(tmp_path) ours_id = _write_object(repo, b"ours") theirs_id = _write_object(repo, b"theirs") res_id = _write_object(repo, b"resolved") ours_m: Manifest = {"f.py": ours_id} theirs_m: Manifest = {"f.py": theirs_id} new_m: Manifest = {"f.py": res_id} plugin = _FakePlugin() record_resolutions( repo, ["f.py::MyFunc"], ours_m, theirs_m, new_m, "code", plugin, manually_resolved={"f.py::MyFunc"}, ) res = list_resolutions(repo, list_patterns(repo)[0].pattern_id) assert res[0].human_verified is True assert res[0].confidence == 1.0 # --------------------------------------------------------------------------- # End-to-end: muse resolve → muse commit → Harmony patterns # --------------------------------------------------------------------------- class TestResolveToHarmonyEndToEnd: @pytest.fixture() def repo(self, tmp_path: pathlib.Path) -> pathlib.Path: _invoke(tmp_path, ["init"]) (tmp_path / "hello.md").write_text("# Hello\n") _invoke(tmp_path, ["code", "add", "."]) _invoke(tmp_path, ["commit", "-m", "initial"]) return tmp_path def test_muse_resolve_sets_manually_resolved_in_merge_state( self, repo: pathlib.Path ) -> None: write_merge_state( repo, base_commit=_BASE, ours_commit=_OURS, theirs_commit=_THEIRS, conflict_paths=["hello.md"], other_branch="feat/x", ) _invoke(repo, ["resolve", "hello.md"]) state = _state(repo) assert "hello.md" in state.manually_resolved def test_muse_resolve_all_sets_all_in_manually_resolved( self, repo: pathlib.Path ) -> None: write_merge_state( repo, base_commit=_BASE, ours_commit=_OURS, theirs_commit=_THEIRS, conflict_paths=["hello.md::A", "hello.md::B"], other_branch="feat/x", ) _invoke(repo, ["resolve", "--all"]) state = _state(repo) assert "hello.md::A" in state.manually_resolved assert "hello.md::B" in state.manually_resolved