"""Phase 3 — Weave-based union strategy for docs and markdown. The 'union' strategy in .museattributes currently silently discards theirs' content (takes ours blob, ignores theirs). The correct behavior uses three_way_merge_lines to interleave both sides' additions — the same line-level union-resolve logic from Phase 2's _independence_merge_blob. This applies to any path matched by a 'union' strategy rule, primarily: - docs/** (documentation additions from both branches always welcome) - *.md (markdown prose additions from both branches) After Phase 3, both sides' additions appear in the merged blob with no conflict markers and no data loss. Test categories --------------- TestUnionStrategyCorrectness — union strategy merges both sides' content TestUnionStrategyNoDuplication — stable lines appear exactly once TestUnionStrategyEdgeCases — ours-only, theirs-only, identical content TestUnionStrategyFallback — no repo_root → graceful fallback to ours """ from __future__ import annotations from collections.abc import Mapping from typing import TYPE_CHECKING import pathlib import pytest from muse.plugins.code.plugin import CodePlugin from muse.core.types import blob_id, long_id if TYPE_CHECKING: from muse.domain import MergeResult # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _oid(content: bytes) -> str: return blob_id(content) def _write_blob(root: pathlib.Path, content: bytes) -> str: from muse.core.object_store import write_object oid = _oid(content) write_object(root, oid, content) return oid def _snap(root: pathlib.Path, files: Mapping[str, bytes]) -> Mapping[str, object]: return { "files": {path: _write_blob(root, content) for path, content in files.items()}, "domain": "code", "directories": [], } def _read_blob(root: pathlib.Path, result: "MergeResult", path: str) -> str: from muse.core.object_store import read_object oid = result.merged["files"][path] raw = read_object(root, oid) assert raw is not None, f"merged blob for {path} not in object store" return raw.decode("utf-8") def _attrs(tmp_path: pathlib.Path, rules: list[dict]) -> None: """Write a .museattributes file with the given rules.""" lines = ['[meta]\ndomain = "code"\n\n'] for rule in rules: lines.append("[[rules]]\n") for k, v in rule.items(): if isinstance(v, str): lines.append(f'{k} = "{v}"\n') else: lines.append(f"{k} = {v}\n") lines.append("\n") (tmp_path / ".museattributes").write_text("".join(lines)) _DOCS_BASE = ( "# Project Guide\n" "\n" "## Overview\n" "The quick brown fox.\n" ) _DOCS_OURS = ( "# Project Guide\n" "\n" "## Overview\n" "The quick brown fox.\n" "\n" "## Installation\n" "Run `pip install muse`.\n" ) _DOCS_THEIRS = ( "# Project Guide\n" "\n" "## Overview\n" "The quick brown fox.\n" "\n" "## Usage\n" "Run `muse status`.\n" ) # --------------------------------------------------------------------------- # TestUnionStrategyCorrectness # --------------------------------------------------------------------------- class TestUnionStrategyCorrectness: """Union strategy must produce a merged blob containing both sides' additions.""" def test_union_merges_both_sides_additions_no_conflict( self, tmp_path: pathlib.Path ) -> None: """docs/README.md with additions on each side → clean merge, no conflict.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/README.md": _DOCS_BASE.encode()}) ours = _snap(tmp_path, {"docs/README.md": _DOCS_OURS.encode()}) theirs = _snap(tmp_path, {"docs/README.md": _DOCS_THEIRS.encode()}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) assert "docs/README.md" not in result.conflicts, ( "Union strategy must not produce a conflict for docs additions" ) def test_union_ours_additions_present_in_merged(self, tmp_path: pathlib.Path) -> None: """Ours' added section appears in the merged blob.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/README.md": _DOCS_BASE.encode()}) ours = _snap(tmp_path, {"docs/README.md": _DOCS_OURS.encode()}) theirs = _snap(tmp_path, {"docs/README.md": _DOCS_THEIRS.encode()}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) merged = _read_blob(tmp_path, result, "docs/README.md") assert "## Installation" in merged, "ours' Installation section must be in merged blob" assert "pip install muse" in merged def test_union_theirs_additions_present_in_merged(self, tmp_path: pathlib.Path) -> None: """Theirs' added section appears in the merged blob.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/README.md": _DOCS_BASE.encode()}) ours = _snap(tmp_path, {"docs/README.md": _DOCS_OURS.encode()}) theirs = _snap(tmp_path, {"docs/README.md": _DOCS_THEIRS.encode()}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) merged = _read_blob(tmp_path, result, "docs/README.md") assert "## Usage" in merged, "theirs' Usage section must be in merged blob" assert "muse status" in merged def test_union_md_glob_rule_works(self, tmp_path: pathlib.Path) -> None: """*.md rule at root level also triggers weave union.""" _attrs(tmp_path, [{"path": "*.md", "dimension": "*", "strategy": "union", "priority": 10}]) plugin = CodePlugin() base = _snap(tmp_path, {"CHANGELOG.md": b"# v1.0\n- initial\n"}) ours = _snap(tmp_path, {"CHANGELOG.md": b"# v1.0\n- initial\n\n# v1.1\n- new feature\n"}) theirs = _snap(tmp_path, {"CHANGELOG.md": b"# v1.0\n- initial\n\n# v1.2\n- hotfix\n"}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) assert "CHANGELOG.md" not in result.conflicts merged = _read_blob(tmp_path, result, "CHANGELOG.md") assert "v1.1" in merged, "ours' changelog entry must appear" assert "v1.2" in merged, "theirs' changelog entry must appear" def test_union_merged_blob_has_no_conflict_markers(self, tmp_path: pathlib.Path) -> None: """Union-merged blob must not contain <<<<<<< conflict markers.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/guide.md": _DOCS_BASE.encode()}) ours = _snap(tmp_path, {"docs/guide.md": _DOCS_OURS.encode()}) theirs = _snap(tmp_path, {"docs/guide.md": _DOCS_THEIRS.encode()}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) merged = _read_blob(tmp_path, result, "docs/guide.md") assert "<<<<<<<" not in merged assert "=======" not in merged assert ">>>>>>>" not in merged # --------------------------------------------------------------------------- # TestUnionStrategyNoDuplication # --------------------------------------------------------------------------- class TestUnionStrategyNoDuplication: """Stable (unchanged) lines must appear exactly once in the merged blob.""" def test_base_content_not_duplicated(self, tmp_path: pathlib.Path) -> None: """The Overview section from base appears only once in the union merge.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/README.md": _DOCS_BASE.encode()}) ours = _snap(tmp_path, {"docs/README.md": _DOCS_OURS.encode()}) theirs = _snap(tmp_path, {"docs/README.md": _DOCS_THEIRS.encode()}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) merged = _read_blob(tmp_path, result, "docs/README.md") assert merged.count("# Project Guide") == 1, "header must appear exactly once" assert merged.count("## Overview") == 1, "stable section must appear exactly once" assert merged.count("The quick brown fox.") == 1, "stable content must not duplicate" def test_same_addition_on_both_sides_deduplicated(self, tmp_path: pathlib.Path) -> None: """Both sides adding the same line → appears once in merged output.""" _attrs(tmp_path, [{"path": "*.md", "dimension": "*", "strategy": "union", "priority": 10}]) plugin = CodePlugin() base = _snap(tmp_path, {"NOTES.md": b"# Notes\n"}) both = b"# Notes\n\n## Common\nAdded by both.\n" ours = _snap(tmp_path, {"NOTES.md": both}) theirs = _snap(tmp_path, {"NOTES.md": both}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) merged = _read_blob(tmp_path, result, "NOTES.md") assert merged.count("## Common") == 1, "consensus addition must appear once" # --------------------------------------------------------------------------- # TestUnionStrategyEdgeCases # --------------------------------------------------------------------------- class TestUnionStrategyEdgeCases: """Edge cases: ours-only, theirs-only, identical content, empty base.""" def test_ours_only_change_preserved(self, tmp_path: pathlib.Path) -> None: """When only ours changed (b == r), ours wins — no duplication.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/api.md": b"# API\n"}) ours = _snap(tmp_path, {"docs/api.md": b"# API\n\n## Methods\n"}) theirs = _snap(tmp_path, {"docs/api.md": b"# API\n"}) # unchanged result = plugin.merge(base, ours, theirs, repo_root=tmp_path) # b == r → takes ours via the non-union path, no conflict assert "docs/api.md" not in result.conflicts merged = _read_blob(tmp_path, result, "docs/api.md") assert "## Methods" in merged def test_theirs_only_change_preserved(self, tmp_path: pathlib.Path) -> None: """When only theirs changed (b == l), theirs wins — no conflict.""" _attrs(tmp_path, [{"path": "docs/**", "dimension": "*", "strategy": "union", "priority": 50}]) plugin = CodePlugin() base = _snap(tmp_path, {"docs/api.md": b"# API\n"}) ours = _snap(tmp_path, {"docs/api.md": b"# API\n"}) # unchanged theirs = _snap(tmp_path, {"docs/api.md": b"# API\n\n## Examples\n"}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) assert "docs/api.md" not in result.conflicts merged = _read_blob(tmp_path, result, "docs/api.md") assert "## Examples" in merged def test_identical_both_sides_no_conflict(self, tmp_path: pathlib.Path) -> None: """Both sides made identical changes → consensus, no duplication.""" _attrs(tmp_path, [{"path": "*.md", "dimension": "*", "strategy": "union", "priority": 10}]) plugin = CodePlugin() base = _snap(tmp_path, {"README.md": b"# Project\n"}) both = b"# Project\n\nBrief description.\n" ours = _snap(tmp_path, {"README.md": both}) theirs = _snap(tmp_path, {"README.md": both}) result = plugin.merge(base, ours, theirs, repo_root=tmp_path) assert "README.md" not in result.conflicts merged = _read_blob(tmp_path, result, "README.md") assert merged.count("Brief description.") == 1 # --------------------------------------------------------------------------- # TestUnionStrategyFallback # --------------------------------------------------------------------------- class TestUnionStrategyFallback: """Without repo_root, union must not crash — graceful fallback.""" def test_no_repo_root_does_not_crash(self) -> None: """merge() called without repo_root still returns a MergeResult.""" plugin = CodePlugin() # No repo_root, so object store is unavailable. The attrs won't load # (load_attributes requires a path), so the union path isn't reached — # this just confirms we don't regress on the no-root fast path. base = {"files": {"a.md": long_id("a" * 64)}, "domain": "code", "directories": []} ours = {"files": {"a.md": long_id("b" * 64)}, "domain": "code", "directories": []} theirs = {"files": {"a.md": long_id("c" * 64)}, "domain": "code", "directories": []} result = plugin.merge(base, ours, theirs) # no repo_root assert result is not None