"""TDD tests for Phase 2 — Conflict granularity specification. Issue #86 Phase 2 deliverables: CE_01: Untouched file → no conflict CE_02: Convergent edit (both branches → same bytes) → no conflict CE_03: Convergent symbol (both branches → same Python function body) → no conflict CE_04: Both deleted → no conflict, file absent from merged snapshot DE_01: File divergence (same path, different bytes) → conflict detected DE_02: Symbol divergence (same function, different bodies) → conflict detected DE_03: Add/add collision (both add same path with different content) → conflict detected DE_04: Delete/modify (one side deletes, other modifies) → conflict detected DIR_01: Directory-path conflict (delete/modify on file inside src/) → conflict detected """ 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 from muse.core.paths import heads_dir, muse_dir, ref_path runner = CliRunner() cli = None # --------------------------------------------------------------------------- # Shared test helpers # --------------------------------------------------------------------------- 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.""" (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") for path, oid in manifest.items(): from muse.core.object_store import read_object 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) def _merged_snapshot(root: pathlib.Path, branch: str) -> dict: """Read the manifest of the current HEAD commit on *branch*.""" from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot from muse.core.refs import resolve_any_ref commit_id = resolve_any_ref(root, branch) assert commit_id is not None rec = read_commit(root, commit_id) assert rec is not None snap = read_snapshot(root, rec.snapshot_id) assert snap is not None return snap.manifest # --------------------------------------------------------------------------- # Group 1 — Convergent sub-cases (must never produce a conflict) # --------------------------------------------------------------------------- class TestConvergentEdits: """All four convergent sub-cases must produce conflicts == [] and a clean status.""" def test_CE_01_untouched_file_no_conflict(self, tmp_path: pathlib.Path) -> None: """File unchanged from base on both sides (identical object IDs) → no conflict.""" root, repo_id = _init_repo(tmp_path) shared_id = _write_obj(root, b"shared file unchanged") x_base = _write_obj(root, b"file_x base") base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_base, "shared.py": shared_id}) # branch-a modifies file_x only; shared.py unchanged x_v2a = _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 modifies x", {"file_x.py": x_v2a, "shared.py": shared_id}, parent_id=base_id) # branch-b modifies file_x only; shared.py unchanged x_v2b = _write_obj(root, b"file_x modified by branch-b") (heads_dir(root) / "branch-b").write_text(base_id) _make_commit(root, repo_id, "branch-b", "b modifies x", {"file_x.py": x_v2b, "shared.py": shared_id}, parent_id=base_id) _checkout(root, "branch-a", {"file_x.py": x_v2a, "shared.py": shared_id}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False) data = json.loads(result.output.strip().splitlines()[-1]) assert "shared.py" not in data.get("conflicts", []), ( "CE_01: untouched shared.py must not appear in conflicts" ) def test_CE_02_convergent_edit_no_conflict(self, tmp_path: pathlib.Path) -> None: """Both branches independently write same bytes to a file → 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}) # Both branches produce the exact same new content x_v2 = _write_obj(root, b"file_x 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", []) == [], ( "CE_02: convergent edit to same content must produce no conflicts" ) assert data.get("status") in ("merged", "fast_forward", "up_to_date"), ( f"CE_02: merge must be clean, got {data.get('status')}" ) # Merged snapshot must contain the convergent content merged = _merged_snapshot(root, "branch-a") assert merged.get("file_x.py") == x_v2, ( "CE_02: merged snapshot must contain the convergent file_x.py version" ) def test_CE_03_convergent_symbol_no_conflict(self, tmp_path: pathlib.Path) -> None: """Both branches update same Python function to identical body → no conflict. Since the final file bytes are identical on both sides (same object ID), the merge engine sees l == r and resolves cleanly at the file level. The code plugin's symbol-level path is also exercised via merge_ops. """ root, repo_id = _init_repo(tmp_path) # Base: module with a simple function base_src = b"def compute(x):\n return x\n" base_id_obj = _write_obj(root, base_src) base_commit = _make_commit(root, repo_id, "main", "base", {"module.py": base_id_obj}) # Both branches independently update compute() to the SAME new body new_src = b"def compute(x):\n return x * 2\n" new_id_obj = _write_obj(root, new_src) (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a updates compute", {"module.py": new_id_obj}, parent_id=base_commit) (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b also updates compute", {"module.py": new_id_obj}, parent_id=base_commit) _checkout(root, "branch-a", {"module.py": new_id_obj}) 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", []) == [], ( "CE_03: convergent symbol update (same result on both sides) must not conflict" ) assert data.get("status") in ("merged", "fast_forward", "up_to_date"), ( f"CE_03: merge must be clean, got {data.get('status')}" ) # Merged snapshot must contain the new version merged = _merged_snapshot(root, "branch-a") assert merged.get("module.py") == new_id_obj, ( "CE_03: merged snapshot must contain the convergently-updated module.py" ) def test_CE_04_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None: """File deleted on both branches → no conflict; file absent from merged snapshot.""" root, repo_id = _init_repo(tmp_path) keep_id = _write_obj(root, b"keeper file") gone_id = _write_obj(root, b"file to be deleted by both") base_commit = _make_commit(root, repo_id, "main", "base", {"keep.py": keep_id, "gone.py": gone_id}) # Both branches delete gone.py (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a deletes gone.py", {"keep.py": keep_id}, parent_id=base_commit) (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b also deletes gone.py", {"keep.py": keep_id}, parent_id=base_commit) _checkout(root, "branch-a", {"keep.py": keep_id}) 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", []) == [], ( "CE_04: both-deleted must produce no conflicts" ) assert data.get("status") in ("merged", "fast_forward", "up_to_date"), ( f"CE_04: merge must be clean, got {data.get('status')}" ) # gone.py must be absent from the merged snapshot merged = _merged_snapshot(root, "branch-a") assert "gone.py" not in merged, ( "CE_04: both-deleted file must be absent from merged snapshot" ) assert merged.get("keep.py") == keep_id, ( "CE_04: unrelated keep.py must survive in merged snapshot" ) # --------------------------------------------------------------------------- # Group 2 — Divergent sub-cases (must produce conflicts) # --------------------------------------------------------------------------- class TestDivergentEdits: """All four divergent sub-cases must surface the conflicting path in conflicts.""" def test_DE_01_file_divergence_detected(self, tmp_path: pathlib.Path) -> None: """Same path modified to different bytes on each branch → path in conflicts.""" root, repo_id = _init_repo(tmp_path) v1 = _write_obj(root, b"config v1") base_commit = _make_commit(root, repo_id, "main", "base", {"config.py": v1}) v2a = _write_obj(root, b"config v2 branch-a") (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a modifies config", {"config.py": v2a}, parent_id=base_commit) v2b = _write_obj(root, b"config v2 branch-b different") (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b modifies config differently", {"config.py": v2b}, parent_id=base_commit) _checkout(root, "branch-a", {"config.py": v2a}) 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("config.py" in c for c in conflicts), ( f"DE_01: config.py file divergence must appear in conflicts, got {conflicts}" ) def test_DE_02_symbol_divergence_detected(self, tmp_path: pathlib.Path) -> None: """Same Python function updated to different bodies on each branch → conflict detected. The conflict address is either the symbol address (module.py::compute) or the file path (module.py) depending on whether merge_ops symbol-level detection fires. Either form is acceptable — the key assertion is that the file path appears somewhere in the conflicts list. """ root, repo_id = _init_repo(tmp_path) base_src = b"def compute(x):\n return x\n\ndef helper():\n pass\n" base_obj = _write_obj(root, base_src) base_commit = _make_commit(root, repo_id, "main", "base", {"module.py": base_obj}) # branch-a changes compute() to multiply src_a = b"def compute(x):\n return x * 2\n\ndef helper():\n pass\n" obj_a = _write_obj(root, src_a) (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a: compute multiplies", {"module.py": obj_a}, parent_id=base_commit) # branch-b changes compute() to add — different result src_b = b"def compute(x):\n return x + 1\n\ndef helper():\n pass\n" obj_b = _write_obj(root, src_b) (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b: compute adds", {"module.py": obj_b}, parent_id=base_commit) _checkout(root, "branch-a", {"module.py": obj_a}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root)) data = json.loads(result.output.strip().splitlines()[-1]) conflicts = data.get("conflicts", []) # Accept either symbol-level ("module.py::compute") or file-level ("module.py") assert any("module.py" in c for c in conflicts), ( f"DE_02: symbol divergence in module.py must appear in conflicts, got {conflicts}" ) def test_DE_03_add_add_collision_detected(self, tmp_path: pathlib.Path) -> None: """Both branches add the same new path with different content → conflict detected.""" root, repo_id = _init_repo(tmp_path) existing_id = _write_obj(root, b"existing file") base_commit = _make_commit(root, repo_id, "main", "base", {"existing.py": existing_id}) # Both branches add new.py — with different content new_a = _write_obj(root, b"new file from branch-a") (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a adds new.py", {"existing.py": existing_id, "new.py": new_a}, parent_id=base_commit) new_b = _write_obj(root, b"new file from branch-b different content") (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b also adds new.py", {"existing.py": existing_id, "new.py": new_b}, parent_id=base_commit) _checkout(root, "branch-a", {"existing.py": existing_id, "new.py": new_a}) 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("new.py" in c for c in conflicts), ( f"DE_03: add/add collision on new.py must appear in conflicts, got {conflicts}" ) def test_DE_04_delete_modify_conflict_detected(self, tmp_path: pathlib.Path) -> None: """One branch deletes a file; the other modifies it → conflict detected.""" root, repo_id = _init_repo(tmp_path) v1 = _write_obj(root, b"service.py v1") base_commit = _make_commit(root, repo_id, "main", "base", {"service.py": v1}) # branch-a DELETES service.py (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a deletes service.py", {}, parent_id=base_commit) # branch-b MODIFIES service.py v2 = _write_obj(root, b"service.py v2 modified") (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b modifies service.py", {"service.py": v2}, parent_id=base_commit) # Checkout branch-a (service.py absent) _checkout(root, "branch-a", {}) 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("service.py" in c for c in conflicts), ( f"DE_04: delete/modify on service.py must appear in conflicts, got {conflicts}" ) # --------------------------------------------------------------------------- # Group 3 — Directory-level conflict # --------------------------------------------------------------------------- class TestDirectoryLevel: """Directory-path conflict: files inside nested paths conflict correctly.""" def test_DIR_01_delete_modify_inside_directory(self, tmp_path: pathlib.Path) -> None: """Delete/modify conflict on a file inside src/ is correctly detected. Scenario: - base has src/core.py and src/utils.py - branch-a deletes src/core.py (removes it from the directory) - branch-b MODIFIES src/core.py (a different version) and adds src/new_module.py Expected: - src/core.py CONFLICTS (one side deleted, other modified) - src/new_module.py merges cleanly (only branch-b added it) - src/utils.py survives unchanged Note: Muse's flat-manifest merge does not track directory objects as first-class entities. A "directory deleted on one side, new file added inside it on the other" scenario does NOT conflict for the new file — the new file simply lands in the merged snapshot. Full directory-level conflict awareness (where src/new_module.py would conflict because src/ was deleted as a unit) requires explicit directory tracking and is planned for a future phase. """ root, repo_id = _init_repo(tmp_path) core_v1 = _write_obj(root, b"src/core.py v1") utils_id = _write_obj(root, b"src/utils.py unchanged") base_commit = _make_commit(root, repo_id, "main", "base", {"src/core.py": core_v1, "src/utils.py": utils_id}) # branch-a: delete src/core.py; keep src/utils.py (heads_dir(root) / "branch-a").write_text(base_commit) _make_commit(root, repo_id, "branch-a", "a deletes src/core.py", {"src/utils.py": utils_id}, parent_id=base_commit) # branch-b: modify src/core.py AND add src/new_module.py core_v2 = _write_obj(root, b"src/core.py v2 modified by branch-b") new_mod = _write_obj(root, b"src/new_module.py added by branch-b") (heads_dir(root) / "branch-b").write_text(base_commit) _make_commit(root, repo_id, "branch-b", "b modifies core and adds new_module", {"src/core.py": core_v2, "src/utils.py": utils_id, "src/new_module.py": new_mod}, parent_id=base_commit) # Checkout branch-a working tree _checkout(root, "branch-a", {"src/utils.py": utils_id}) result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root)) data = json.loads(result.output.strip().splitlines()[-1]) conflicts = data.get("conflicts", []) # The delete/modify on src/core.py MUST conflict assert any("src/core.py" in c for c in conflicts), ( f"DIR_01: delete/modify conflict on src/core.py must be detected, got {conflicts}" ) # src/utils.py (unchanged on both sides) must NOT conflict assert not any("src/utils.py" in c for c in conflicts), ( f"DIR_01: untouched src/utils.py must not appear in conflicts, got {conflicts}" ) # src/new_module.py (added only by branch-b) must NOT conflict # (flat-manifest model: new file from one side merges cleanly) assert not any("src/new_module.py" in c for c in conflicts), ( f"DIR_01: new file src/new_module.py added only by branch-b must not conflict, got {conflicts}" )