"""TDD tests for idiomatic conflict resolution via muse code add. In git, `git add ` after editing a conflict file is the resolution signal — no separate step needed. muse code add must behave the same way: staging a file that is in conflict_paths removes it from MERGE_STATE.json automatically, so `muse commit` can proceed without an explicit `muse resolve`. """ from __future__ import annotations import datetime import json import pathlib from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner 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 muse.core.merge_engine import read_merge_state, write_merge_state runner = CliRunner() cli = None type Manifest = dict[str, str] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- 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, files: dict[str, bytes], parent_id: str | None = None, message: str = "commit", ) -> 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 manifest: Manifest = {} for rel, content in files.items(): oid = _write_blob(root, content) manifest[rel] = oid dest = root / rel dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(content) snap_id = hash_snapshot(manifest) 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=manifest)) 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, )) rf = ref_path(root, branch) rf.parent.mkdir(parents=True, exist_ok=True) rf.write_text(commit_id, encoding="utf-8") return commit_id def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _do_conflicted_merge(tmp_path: pathlib.Path) -> pathlib.Path: """Set up a repo with an in-progress conflict on hello.txt and return root.""" root, repo_id = _init_repo(tmp_path) base_id = _make_commit(root, repo_id, "main", {"hello.txt": b"base\n"}, message="base") (heads_dir(root) / "feat").write_text(base_id) _make_commit(root, repo_id, "feat", {"hello.txt": b"Version B\n"}, parent_id=base_id, message="feat") _make_commit(root, repo_id, "main", {"hello.txt": b"Version A\n"}, parent_id=base_id, message="main") runner.invoke(cli, ["merge", "feat"], env=_env(root)) # Confirm merge is in progress with hello.txt as a conflict state = read_merge_state(root) assert state is not None and "hello.txt" in state.conflict_paths return root # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestCodeAddClearsConflictPath: def test_staging_conflict_file_removes_it_from_conflict_paths( self, tmp_path: pathlib.Path ) -> None: """After manually editing and staging, hello.txt must leave conflict_paths.""" root = _do_conflicted_merge(tmp_path) (root / "hello.txt").write_bytes(b"Version B\n") runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root)) state = read_merge_state(root) assert state is None or "hello.txt" not in state.conflict_paths, ( "hello.txt must be removed from conflict_paths after muse code add" ) def test_staging_conflict_file_allows_commit( self, tmp_path: pathlib.Path ) -> None: """muse commit must succeed after staging the resolved conflict file.""" root = _do_conflicted_merge(tmp_path) (root / "hello.txt").write_bytes(b"Version B\n") runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root)) result = runner.invoke( cli, ["commit", "-m", "merge: resolve hello.txt — keep version B"], env=_env(root), ) assert result.exit_code == 0, ( f"commit should succeed after staging resolved file, got:\n{result.stderr}" ) def test_staging_conflict_file_sets_ready_to_commit_when_last_conflict( self, tmp_path: pathlib.Path ) -> None: """When the last conflict is staged, merge_in_progress must become false.""" root = _do_conflicted_merge(tmp_path) (root / "hello.txt").write_bytes(b"Version B\n") runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root)) status = json.loads( runner.invoke(cli, ["status", "--json"], env=_env(root)).stdout ) assert status["merge_in_progress"] is False or status["conflict_count"] == 0, ( "No conflicts should remain after staging the only conflict file" ) def test_staging_non_conflict_file_does_not_touch_merge_state( self, tmp_path: pathlib.Path ) -> None: """Staging an unrelated file must not affect MERGE_STATE conflict list.""" root = _do_conflicted_merge(tmp_path) (root / "notes.txt").write_bytes(b"some notes\n") runner.invoke(cli, ["code", "add", str(root / "notes.txt")], env=_env(root)) state = read_merge_state(root) assert state is not None and "hello.txt" in state.conflict_paths, ( "hello.txt must remain in conflict_paths when an unrelated file is staged" ) def test_staging_one_of_two_conflict_files_leaves_other_unresolved( self, tmp_path: pathlib.Path ) -> None: """Staging one conflicted file must leave the other in conflict_paths.""" root, repo_id = _init_repo(tmp_path) base_id = _make_commit(root, repo_id, "main", { "alpha.txt": b"base alpha\n", "beta.txt": b"base beta\n", }, message="base") (heads_dir(root) / "feat").write_text(base_id) _make_commit(root, repo_id, "feat", { "alpha.txt": b"theirs alpha\n", "beta.txt": b"theirs beta\n", }, parent_id=base_id, message="feat") _make_commit(root, repo_id, "main", { "alpha.txt": b"ours alpha\n", "beta.txt": b"ours beta\n", }, parent_id=base_id, message="main") runner.invoke(cli, ["merge", "feat"], env=_env(root)) (root / "alpha.txt").write_bytes(b"resolved alpha\n") runner.invoke(cli, ["code", "add", str(root / "alpha.txt")], env=_env(root)) state = read_merge_state(root) assert state is not None assert "alpha.txt" not in state.conflict_paths, ( "alpha.txt must be cleared after staging" ) assert "beta.txt" in state.conflict_paths, ( "beta.txt must remain unresolved" ) def test_code_add_dot_clears_all_conflict_files( self, tmp_path: pathlib.Path ) -> None: """muse code add must clear all conflicted files from conflict_paths.""" root, repo_id = _init_repo(tmp_path) base_id = _make_commit(root, repo_id, "main", { "alpha.txt": b"base alpha\n", "beta.txt": b"base beta\n", }, message="base") (heads_dir(root) / "feat").write_text(base_id) _make_commit(root, repo_id, "feat", { "alpha.txt": b"theirs alpha\n", "beta.txt": b"theirs beta\n", }, parent_id=base_id, message="feat") _make_commit(root, repo_id, "main", { "alpha.txt": b"ours alpha\n", "beta.txt": b"ours beta\n", }, parent_id=base_id, message="main") runner.invoke(cli, ["merge", "feat"], env=_env(root)) (root / "alpha.txt").write_bytes(b"resolved alpha\n") (root / "beta.txt").write_bytes(b"resolved beta\n") runner.invoke(cli, ["code", "add", str(root / "alpha.txt"), str(root / "beta.txt")], env=_env(root)) state = read_merge_state(root) remaining = state.conflict_paths if state else [] assert remaining == [], ( f"All conflicts must be cleared after staging all files, got: {remaining}" ) def test_no_merge_in_progress_code_add_is_unaffected( self, tmp_path: pathlib.Path ) -> None: """muse code add must work normally when no merge is in progress.""" root, repo_id = _init_repo(tmp_path) _make_commit(root, repo_id, "main", {"hello.txt": b"hello\n"}, message="init") (root / "hello.txt").write_bytes(b"hello world\n") result = runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root)) assert result.exit_code == 0 assert read_merge_state(root) is None