"""Phase 3 of issue #8: commit guard alignment. Coverage -------- - commit guard reads conflict_paths (mutable), not original_conflict_paths - after resolve_path clears all conflicts, muse commit succeeds - after resolve_symbol clears all conflicts, muse commit succeeds - muse commit is still blocked while conflicts remain - original_conflict_paths is preserved in MERGE_STATE after resolve (harmony can learn) - MERGE_STATE is cleared by a successful commit - muse resolve CLI → muse commit CLI full flow exits 0 - muse resolve auto-stages the file (no separate code add needed before commit) """ from __future__ import annotations import json import os import pathlib import pytest from muse.core.merge_engine import ( MergeState, read_merge_state, resolve_path, resolve_symbol, write_merge_state, ) from muse.core.types import MUSE_DIR, 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 _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) @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Initialised code repo with one committed file.""" _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 _set_merge_state(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 # --------------------------------------------------------------------------- # Commit guard alignment — unit verification # --------------------------------------------------------------------------- class TestCommitGuardReadsConflictPaths: """The commit guard must read conflict_paths, not original_conflict_paths.""" def test_commit_blocked_while_conflict_paths_nonempty( self, repo: pathlib.Path ) -> None: """Commit must fail when conflict_paths contains entries.""" _set_merge_state(repo, ["hello.md"]) (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "should fail"]) assert r.exit_code != 0 def test_commit_blocked_message_mentions_conflict( self, repo: pathlib.Path ) -> None: """Commit failure message should reference the conflict.""" _set_merge_state(repo, ["hello.md"]) (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "should fail"]) assert "conflict" in r.output.lower() or "conflict" in (r.stderr or "").lower() def test_commit_succeeds_when_conflict_paths_empty( self, repo: pathlib.Path ) -> None: """Commit must succeed when conflict_paths is [] even if original_conflict_paths is set.""" _set_merge_state(repo, []) (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: manual resolve"]) assert r.exit_code == 0 def test_original_conflict_paths_does_not_block_commit( self, repo: pathlib.Path ) -> None: """Clearing conflict_paths is sufficient — original_conflict_paths should not block.""" # Write state with conflicts, then clear them via resolve_path. _set_merge_state(repo, ["hello.md"]) resolve_path(repo, "hello.md") state = _state(repo) assert state.conflict_paths == [] assert state.original_conflict_paths == ["hello.md"] (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: manual resolve"]) assert r.exit_code == 0 # --------------------------------------------------------------------------- # resolve_path → commit # --------------------------------------------------------------------------- class TestResolvePathThenCommit: def test_resolve_path_unblocks_commit(self, repo: pathlib.Path) -> None: _set_merge_state(repo, ["hello.md"]) resolve_path(repo, "hello.md") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: resolved"]) assert r.exit_code == 0 def test_resolve_path_partial_still_blocks(self, repo: pathlib.Path) -> None: (repo / "other.md").write_text("other\n") _invoke(repo, ["code", "add", "."]) _invoke(repo, ["commit", "-m", "add other"]) _set_merge_state(repo, ["hello.md", "other.md"]) resolve_path(repo, "hello.md") # other.md is still conflicted — commit must fail (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "should still fail"]) assert r.exit_code != 0 def test_resolve_all_paths_unblocks_commit(self, repo: pathlib.Path) -> None: (repo / "other.md").write_text("other\n") _invoke(repo, ["code", "add", "."]) _invoke(repo, ["commit", "-m", "add other"]) _set_merge_state(repo, ["hello.md", "other.md"]) resolve_path(repo, "hello.md") resolve_path(repo, "other.md") (repo / "hello.md").write_text("# Resolved\n") (repo / "other.md").write_text("resolved other\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: all resolved"]) assert r.exit_code == 0 def test_merge_state_cleared_after_commit(self, repo: pathlib.Path) -> None: _set_merge_state(repo, ["hello.md"]) resolve_path(repo, "hello.md") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) _invoke(repo, ["commit", "-m", "merge: resolved"]) assert read_merge_state(repo) is None def test_original_conflict_paths_preserved_until_commit( self, repo: pathlib.Path ) -> None: """original_conflict_paths must survive resolve so Harmony can learn at commit.""" _set_merge_state(repo, ["hello.md"]) resolve_path(repo, "hello.md") state = _state(repo) assert state.original_conflict_paths == ["hello.md"] def test_symbol_level_conflicts_cleared_by_resolve_path( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md::Hello World", "hello.md::Subtitle"]) resolve_path(repo, "hello.md") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: resolved"]) assert r.exit_code == 0 # --------------------------------------------------------------------------- # resolve_symbol → commit # --------------------------------------------------------------------------- class TestResolveSymbolThenCommit: def test_resolve_symbol_unblocks_commit_when_last( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md::Hello World"]) resolve_symbol(repo, "hello.md::Hello World") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: resolved"]) assert r.exit_code == 0 def test_resolve_symbol_partial_still_blocks(self, repo: pathlib.Path) -> None: _set_merge_state(repo, ["hello.md::A", "hello.md::B"]) resolve_symbol(repo, "hello.md::A") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "should still fail"]) assert r.exit_code != 0 def test_resolve_all_symbols_unblocks_commit(self, repo: pathlib.Path) -> None: _set_merge_state(repo, ["hello.md::A", "hello.md::B"]) resolve_symbol(repo, "hello.md::A") resolve_symbol(repo, "hello.md::B") (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["code", "add", "."]) r = _invoke(repo, ["commit", "-m", "merge: all resolved"]) assert r.exit_code == 0 # --------------------------------------------------------------------------- # muse resolve CLI → muse commit CLI — full end-to-end # --------------------------------------------------------------------------- class TestResolveCliThenCommitCli: def test_resolve_cli_file_then_commit_succeeds( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md"]) (repo / "hello.md").write_text("# Manually resolved\n") r = _invoke(repo, ["resolve", "hello.md"]) assert r.exit_code == 0 _invoke(repo, ["code", "add", "."]) r2 = _invoke(repo, ["commit", "-m", "merge: resolved"]) assert r2.exit_code == 0 def test_resolve_cli_symbol_then_commit_succeeds( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md::Hello World"]) (repo / "hello.md").write_text("# Manually resolved\n") r = _invoke(repo, ["resolve", "hello.md::Hello World"]) assert r.exit_code == 0 _invoke(repo, ["code", "add", "."]) r2 = _invoke(repo, ["commit", "-m", "merge: resolved"]) assert r2.exit_code == 0 def test_resolve_cli_all_then_commit_succeeds( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md::A", "hello.md::B"]) (repo / "hello.md").write_text("# Manually resolved\n") r = _invoke(repo, ["resolve", "--all"]) assert r.exit_code == 0 _invoke(repo, ["code", "add", "."]) r2 = _invoke(repo, ["commit", "-m", "merge: all resolved"]) assert r2.exit_code == 0 def test_resolve_cli_json_output(self, repo: pathlib.Path) -> None: _set_merge_state(repo, ["hello.md::A", "hello.md::B"]) r = _invoke(repo, ["resolve", "hello.md", "--json"]) assert r.exit_code == 0 data = json.loads(r.output) assert set(data["resolved"]) == {"hello.md::A", "hello.md::B"} assert data["remaining"] == 0 assert data["ready_to_commit"] is True def test_resolve_cli_no_merge_in_progress_exits_nonzero( self, repo: pathlib.Path ) -> None: r = _invoke(repo, ["resolve", "hello.md"]) assert r.exit_code != 0 def test_resolve_cli_already_resolved_exits_zero( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["other.md"]) r = _invoke(repo, ["resolve", "hello.md"]) assert r.exit_code == 0 # warn, not error def test_merge_state_absent_after_full_flow( self, repo: pathlib.Path ) -> None: _set_merge_state(repo, ["hello.md"]) (repo / "hello.md").write_text("# Resolved\n") _invoke(repo, ["resolve", "hello.md"]) _invoke(repo, ["code", "add", "."]) _invoke(repo, ["commit", "-m", "merge: done"]) assert read_merge_state(repo) is None def test_resolve_auto_stages_file(self, repo: pathlib.Path) -> None: """muse resolve stages the file automatically — no separate code add needed.""" _set_merge_state(repo, ["hello.md"]) (repo / "hello.md").write_text("# Manually resolved\n") _invoke(repo, ["resolve", "hello.md"]) # Commit must succeed without any intervening muse code add. r = _invoke(repo, ["commit", "-m", "merge: auto-staged"]) assert r.exit_code == 0 def test_resolve_all_auto_stages_files(self, repo: pathlib.Path) -> None: """muse resolve --all stages every resolved file automatically.""" (repo / "world.md").write_text("# World\n") _invoke(repo, ["code", "add", "."]) _invoke(repo, ["commit", "-m", "add world"]) _set_merge_state(repo, ["hello.md", "world.md"]) (repo / "hello.md").write_text("# Hello resolved\n") (repo / "world.md").write_text("# World resolved\n") _invoke(repo, ["resolve", "--all"]) r = _invoke(repo, ["commit", "-m", "merge: all auto-staged"]) assert r.exit_code == 0 def test_resolve_symbol_auto_stages_parent_file( self, repo: pathlib.Path ) -> None: """muse resolve file::sym stages the parent file automatically.""" _set_merge_state(repo, ["hello.md::A", "hello.md::B"]) (repo / "hello.md").write_text("# All resolved\n") _invoke(repo, ["resolve", "hello.md::A"]) _invoke(repo, ["resolve", "hello.md::B"]) # No code add — both resolve calls staged hello.md. r = _invoke(repo, ["commit", "-m", "merge: symbol auto-staged"]) assert r.exit_code == 0