"""TDD regression tests for issue #85 -- phantom merge conflicts. All tests in this file must be RED before the fix and GREEN after. """ from __future__ import annotations import datetime import json import pathlib 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, read_object from muse.core.paths import heads_dir, muse_dir, ref_path runner = CliRunner() cli = None 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_obj(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, parent_id: str | 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) if parent_id is None: 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 _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None: """Set HEAD to branch and write manifest files to disk (simulate checkout).""" (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") for path, oid in manifest.items(): content = read_object(root, oid) if content is not None: dest = root / path dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(content) class TestPhantomConflicts: """PHANTOM_01 through PHANTOM_05 -- all must be RED before the fix.""" def test_PHANTOM_01_untouched_file_never_conflicts(self, tmp_path: pathlib.Path) -> None: """File untouched by either branch must not appear in conflicts.""" root, repo_id = _init_repo(tmp_path) x_base = _write_obj(root, b"file_x v1") y_base = _write_obj(root, b"file_y unchanged") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_base, "file_y.py": y_base}) x_v2a = _write_obj(root, b"file_x v2a") (heads_dir(root) / "branch-a").write_text(base_id) _make_commit(root, repo_id, "branch-a", "a changes x", {"file_x.py": x_v2a, "file_y.py": y_base}, parent_id=base_id) x_v2b = _write_obj(root, b"file_x v2b") (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b changes x", {"file_x.py": x_v2b, "file_y.py": y_base}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2a, "file_y.py": y_base}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "file_y.py" not in data.get("conflicts", []), ( "PHANTOM_01: untouched file_y.py must not appear in conflicts" ) def test_PHANTOM_02_convergent_edit_no_conflict(self, tmp_path: pathlib.Path) -> None: """Both branches arrive at identical content -- no conflict.""" root, repo_id = _init_repo(tmp_path) x_v1 = _write_obj(root, b"file_x v1") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_v1}) x_v2 = _write_obj(root, b"file_x v2 same on both branches") (heads_dir(root) / "branch-a").write_text(base_id) _make_commit(root, repo_id, "branch-a", "a to v2", {"file_x.py": x_v2}, parent_id=base_id) (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b to v2", {"file_x.py": x_v2}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert data.get("conflicts", []) == [], ( "PHANTOM_02: convergent edit to same content must produce no conflicts" ) assert data.get("status") in ("merged", "fast_forward", "up_to_date"), ( f"PHANTOM_02: merge must be clean, got {data.get('status')}" ) def test_PHANTOM_03_state_merge_strategy_untouched_file(self, tmp_path: pathlib.Path) -> None: """state_merge strategy: untouched file must not appear in conflicts.""" root, repo_id = _init_repo(tmp_path) x_base = _write_obj(root, b"file_x v1") y_base = _write_obj(root, b"file_y unchanged") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_base, "file_y.py": y_base}) x_v2a = _write_obj(root, b"file_x v2a") (heads_dir(root) / "branch-a").write_text(base_id) _make_commit(root, repo_id, "branch-a", "a changes x", {"file_x.py": x_v2a, "file_y.py": y_base}, parent_id=base_id) x_v2b = _write_obj(root, b"file_x v2b") (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b changes x", {"file_x.py": x_v2b, "file_y.py": y_base}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2a, "file_y.py": y_base}) result = runner.invoke(cli, ["merge", "branch-b", "--strategy", "state_merge", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "file_y.py" not in data.get("conflicts", []), ( "PHANTOM_03: state_merge -- untouched file_y.py must not appear in conflicts" ) def test_PHANTOM_04_real_conflict_still_detected(self, tmp_path: pathlib.Path) -> None: """Genuinely divergent edits must still be reported as a conflict.""" root, repo_id = _init_repo(tmp_path) x_v1 = _write_obj(root, b"file_x v1") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_v1}) x_v2 = _write_obj(root, b"file_x v2 branch A") (heads_dir(root) / "branch-a").write_text(base_id) _make_commit(root, repo_id, "branch-a", "a to v2", {"file_x.py": x_v2}, parent_id=base_id) x_v3 = _write_obj(root, b"file_x v3 branch B different") (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b to v3", {"file_x.py": x_v3}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root)) data = json.loads(result.output.strip().splitlines()[-1]) conflicts = data.get("conflicts", []) assert any("file_x.py" in c for c in conflicts), ( f"PHANTOM_04: real conflict on file_x.py must be detected, got conflicts={conflicts}" ) def test_PHANTOM_05_clean_merge_snapshot_has_both_branches(self, tmp_path: pathlib.Path) -> None: """Clean merge commit snapshot must contain changes from both branches.""" root, repo_id = _init_repo(tmp_path) x_base = _write_obj(root, b"file_x base") z_base = _write_obj(root, b"file_z base") y_base = _write_obj(root, b"file_y unchanged") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_base, "file_z.py": z_base, "file_y.py": y_base}) # Branch A modifies file_x.py only x_v2 = _write_obj(root, b"file_x modified by branch-a") (heads_dir(root) / "branch-a").write_text(base_id) _make_commit(root, repo_id, "branch-a", "a changes x", {"file_x.py": x_v2, "file_z.py": z_base, "file_y.py": y_base}, parent_id=base_id) # Branch B modifies file_z.py only z_v2 = _write_obj(root, b"file_z modified by branch-b") (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b changes z", {"file_x.py": x_base, "file_z.py": z_v2, "file_y.py": y_base}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2, "file_z.py": z_base, "file_y.py": y_base}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) # No --allow-empty needed: merge must be clean assert data.get("conflicts", []) == [], ( f"PHANTOM_05: merge must be clean, got conflicts={data.get('conflicts')}" ) assert data.get("status") in ("merged", "fast_forward"), ( f"PHANTOM_05: merge must complete, got status={data.get('status')}" ) # Verify the merge commit snapshot contains BOTH branches' changes from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot merge_commit_id = ref_path(root, "branch-a").read_text().strip() commit_rec = read_commit(root, merge_commit_id) assert commit_rec is not None snap_rec = read_snapshot(root, commit_rec.snapshot_id) assert snap_rec is not None merged = snap_rec.manifest assert merged.get("file_x.py") == x_v2, ( "PHANTOM_05: merge snapshot must contain branch-a's file_x.py change" ) assert merged.get("file_z.py") == z_v2, ( "PHANTOM_05: merge snapshot must contain branch-b's file_z.py change" )