"""Integration tests: .museattributes × CodePlugin.merge() Verifies that every merge strategy (ours, theirs, base, union, manual, auto) is correctly honoured by CodePlugin.merge() and merge_ops() when a .museattributes file is present in the repo root. Change scenarios tested for each strategy ------------------------------------------ UNCHANGED b==l==r neither branch touched the file CONVERGENT b!=l==r both branches made the same edit OURS_ONLY b==r, l!=b only our branch changed it THEIRS_ONLY b==l, r!=b only their branch changed it DIVERGENT b!=l, b!=r, l!=r both changed differently BOTH_DELETED b!=None, l==r==None both deleted OURS_DEL_THEIRS_MOD l==None, r!=b ours deleted, theirs modified OURS_MOD_THEIRS_DEL l!=b, r==None ours modified, theirs deleted ONLY_OURS_ADDED b==None, l!=None, r==None ours added new file ONLY_THEIRS_ADDED b==None, l==None, r!=None theirs added new file BOTH_ADDED_SAME b==None, l==r!=None both added identical BOTH_ADDED_DIFF b==None, l!=r (both non-None) both added different """ from collections.abc import Mapping import pathlib import pytest from muse.core.attributes import AttributeRule from muse.core.types import blob_id from muse.core.object_store import write_object from muse.domain import MergeResult, SnapshotManifest from muse.plugins.code.plugin import CodePlugin # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- plugin = CodePlugin() _A_DATA = b"\x00\x01\x02binary-blob-a\xff\xfe" _B_DATA = b"\x00\x01\x02binary-blob-b\xff\xfe" _C_DATA = b"\x00\x01\x02binary-blob-c\xff\xfe" _D_DATA = b"\x00\x01\x02binary-blob-d\xff\xfe" _A_HASH = blob_id(_A_DATA) _B_HASH = blob_id(_B_DATA) _C_HASH = blob_id(_C_DATA) _D_HASH = blob_id(_D_DATA) # Readable aliases used in permutation comments _OLD = _A_HASH _NEW = _B_HASH _ALT = _C_HASH PATH = "src/target.py" # TOML helpers def _rule(strategy: str, path: str = PATH) -> str: return f'[[rules]]\npath = "{path}"\ndimension = "*"\nstrategy = "{strategy}"\n' def _snap(*pairs: tuple[str, str]) -> SnapshotManifest: return SnapshotManifest(files=dict(pairs), domain="code") def _write_attrs(root: pathlib.Path, content: str) -> None: (root / ".museattributes").write_text(content, encoding="utf-8") def _setup_objects(root: pathlib.Path) -> None: for data, oid in ( (_A_DATA, _A_HASH), (_B_DATA, _B_HASH), (_C_DATA, _C_HASH), (_D_DATA, _D_HASH), ): write_object(root, oid, data) def _merge( base: SnapshotManifest, ours: SnapshotManifest, theirs: SnapshotManifest, root: pathlib.Path, ) -> MergeResult: _setup_objects(root) return plugin.merge(base, ours, theirs, repo_root=root) def _files(result: MergeResult) -> Mapping[str, object]: return result.merged["files"] # --------------------------------------------------------------------------- # Strategy: ours # --------------------------------------------------------------------------- class TestOursStrategy: def test_ours_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "src/utils.py"\ndimension = "*"\nstrategy = "ours"\n', ) base = _snap(("src/utils.py", _A_HASH)) left = _snap(("src/utils.py", _B_HASH)) # left changed right = _snap(("src/utils.py", _C_HASH)) # right changed result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert result.merged["files"]["src/utils.py"] == _B_HASH assert result.applied_strategies["src/utils.py"] == "ours" def test_ours_glob_resolves_multiple_files(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "src/**"\ndimension = "*"\nstrategy = "ours"\n', ) base = _snap(("src/a.py", _A_HASH), ("src/b.py", _A_HASH)) left = _snap(("src/a.py", _B_HASH), ("src/b.py", _B_HASH)) right = _snap(("src/a.py", _C_HASH), ("src/b.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert result.merged["files"]["src/a.py"] == _B_HASH assert result.merged["files"]["src/b.py"] == _B_HASH assert result.applied_strategies["src/a.py"] == "ours" # --------------------------------------------------------------------------- # Strategy: theirs # --------------------------------------------------------------------------- class TestTheirsStrategy: def test_theirs_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "config.toml"\ndimension = "*"\nstrategy = "theirs"\n', ) base = _snap(("config.toml", _A_HASH)) left = _snap(("config.toml", _B_HASH)) right = _snap(("config.toml", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert result.merged["files"]["config.toml"] == _C_HASH assert result.applied_strategies["config.toml"] == "theirs" # --------------------------------------------------------------------------- # Strategy: base # --------------------------------------------------------------------------- class TestBaseStrategy: def test_base_reverts_both_branch_changes(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "lock.json"\ndimension = "*"\nstrategy = "base"\n', ) base = _snap(("lock.json", _A_HASH)) left = _snap(("lock.json", _B_HASH)) right = _snap(("lock.json", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert result.merged["files"]["lock.json"] == _A_HASH assert result.applied_strategies["lock.json"] == "base" def test_base_removes_file_when_base_deleted_it(self, tmp_path: pathlib.Path) -> None: """base strategy on a file absent in base removes it from merge.""" _write_attrs( tmp_path, '[[rules]]\npath = "generated.py"\ndimension = "*"\nstrategy = "base"\n', ) # File was absent in base, added by both sides differently. base = _snap() left = _snap(("generated.py", _B_HASH)) right = _snap(("generated.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert "generated.py" not in result.merged["files"] assert result.applied_strategies["generated.py"] == "base" # --------------------------------------------------------------------------- # Strategy: union # --------------------------------------------------------------------------- class TestUnionStrategy: def test_union_keeps_left_for_binary_blob_conflict( self, tmp_path: pathlib.Path ) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "docs/*"\ndimension = "*"\nstrategy = "union"\n', ) base = _snap(("docs/api.md", _A_HASH)) left = _snap(("docs/api.md", _B_HASH)) right = _snap(("docs/api.md", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert result.conflicts == [] assert result.merged["files"]["docs/api.md"] == _B_HASH assert result.applied_strategies["docs/api.md"] == "union" def test_union_keeps_additions_from_both_sides( self, tmp_path: pathlib.Path ) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "tests/**"\ndimension = "*"\nstrategy = "union"\n', ) base = _snap() left = _snap(("tests/test_a.py", _A_HASH)) right = _snap(("tests/test_b.py", _B_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) # Both new files appear — neither is a conflict. assert "tests/test_a.py" in result.merged["files"] assert "tests/test_b.py" in result.merged["files"] assert result.conflicts == [] # --------------------------------------------------------------------------- # Strategy: manual # --------------------------------------------------------------------------- class TestManualStrategy: def test_manual_forces_conflict_on_auto_resolved_path( self, tmp_path: pathlib.Path ) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n', ) # Only left changed — auto would resolve cleanly. base = _snap(("src/core.py", _A_HASH)) left = _snap(("src/core.py", _B_HASH)) right = _snap(("src/core.py", _A_HASH)) # right unchanged result = plugin.merge(base, left, right, repo_root=tmp_path) assert "src/core.py" in result.conflicts assert result.applied_strategies["src/core.py"] == "manual" def test_manual_forces_conflict_on_bilateral_conflict( self, tmp_path: pathlib.Path ) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n', ) base = _snap(("src/core.py", _A_HASH)) left = _snap(("src/core.py", _B_HASH)) right = _snap(("src/core.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert "src/core.py" in result.conflicts assert result.applied_strategies["src/core.py"] == "manual" # --------------------------------------------------------------------------- # Strategy: auto (default) # --------------------------------------------------------------------------- class TestAutoStrategy: def test_no_attrs_file_produces_standard_conflicts( self, tmp_path: pathlib.Path ) -> None: base = _snap(("src/a.py", _A_HASH)) left = _snap(("src/a.py", _B_HASH)) right = _snap(("src/a.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert "src/a.py" in result.conflicts assert result.applied_strategies == {} def test_auto_strategy_is_standard_conflict(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n', ) base = _snap(("src/a.py", _A_HASH)) left = _snap(("src/a.py", _B_HASH)) right = _snap(("src/a.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) assert "src/a.py" in result.conflicts # "auto" never appears in applied_strategies — it's the silent default. assert "src/a.py" not in result.applied_strategies # --------------------------------------------------------------------------- # Priority ordering # --------------------------------------------------------------------------- class TestPriorityInMerge: def test_high_priority_rule_beats_catch_all(self, tmp_path: pathlib.Path) -> None: _write_attrs( tmp_path, '[[rules]]\n' 'path = "*"\ndimension = "*"\nstrategy = "theirs"\npriority = 0\n\n' '[[rules]]\n' 'path = "src/core.py"\ndimension = "*"\nstrategy = "ours"\npriority = 50\n', ) base = _snap(("src/core.py", _A_HASH)) left = _snap(("src/core.py", _B_HASH)) right = _snap(("src/core.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=tmp_path) # High-priority "ours" rule fires, not the catch-all "theirs". assert result.merged["files"]["src/core.py"] == _B_HASH assert result.applied_strategies["src/core.py"] == "ours" # --------------------------------------------------------------------------- # No repo_root — graceful degradation # --------------------------------------------------------------------------- class TestNoRepoRoot: def test_merge_without_repo_root_ignores_attributes(self) -> None: base = _snap(("a.py", _A_HASH)) left = _snap(("a.py", _B_HASH)) right = _snap(("a.py", _C_HASH)) result = plugin.merge(base, left, right, repo_root=None) assert "a.py" in result.conflicts assert result.applied_strategies == {} # --------------------------------------------------------------------------- # applied_strategies propagation through merge_ops # --------------------------------------------------------------------------- class TestMergeOpsAttributePropagation: def test_applied_strategies_flow_through_merge_ops( self, tmp_path: pathlib.Path ) -> None: _write_attrs( tmp_path, '[[rules]]\npath = "src/a.py"\ndimension = "*"\nstrategy = "ours"\n', ) base = _snap(("src/a.py", _A_HASH)) ours = _snap(("src/a.py", _B_HASH)) theirs = _snap(("src/a.py", _C_HASH)) result: MergeResult = plugin.merge_ops( base, ours, theirs, ours_ops=[], theirs_ops=[], repo_root=tmp_path, ) assert result.applied_strategies.get("src/a.py") == "ours" # =========================================================================== # Comprehensive permutation matrix: every strategy × every change scenario # =========================================================================== # # Each class tests one strategy across all 12 change scenarios. # Scenarios where both sides agree (l == r) never produce a conflict, # regardless of strategy. "manual" is the only strategy that fires on # single-branch changes; all others let those through cleanly. # =========================================================================== class TestOursPermutations: """Strategy: ours — take our (left) version when divergent.""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("ours")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean and _files(r).get(PATH) == _OLD def test_convergent(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_only(self, tmp_path: pathlib.Path) -> None: """Single-branch change (ours): ours does NOT force conflict.""" r = self._m(tmp_path, _OLD, _NEW, _OLD) assert r.is_clean and _files(r).get(PATH) == _NEW def test_theirs_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_takes_ours(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "ours" def test_both_deleted(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, None) assert r.is_clean and PATH not in _files(r) def test_both_added_same(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_both_added_different_takes_ours(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "ours" def test_only_ours_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, None) assert r.is_clean and _files(r).get(PATH) == _NEW def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, None, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_modified_theirs_deleted_takes_ours(self, tmp_path: pathlib.Path) -> None: """Divergent: ours modified, theirs deleted → ours strategy keeps ours modification.""" r = self._m(tmp_path, _OLD, _NEW, None) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "ours" def test_ours_deleted_theirs_modified_ours_wins(self, tmp_path: pathlib.Path) -> None: """Divergent: ours deleted, theirs modified → ours strategy deletes the file.""" r = self._m(tmp_path, _OLD, None, _NEW) assert r.is_clean assert PATH not in _files(r) assert r.applied_strategies.get(PATH) == "ours" class TestTheirsPermutations: """Strategy: theirs — take their (right) version when divergent.""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("theirs")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean and _files(r).get(PATH) == _OLD def test_convergent(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _OLD) assert r.is_clean and _files(r).get(PATH) == _NEW def test_theirs_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_takes_theirs(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _ALT assert r.applied_strategies.get(PATH) == "theirs" def test_both_deleted(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, None) assert r.is_clean and PATH not in _files(r) def test_both_added_same(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_both_added_different_takes_theirs(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _ALT assert r.applied_strategies.get(PATH) == "theirs" def test_only_ours_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, None) assert r.is_clean and _files(r).get(PATH) == _NEW def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, None, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_deleted_theirs_modified_takes_theirs(self, tmp_path: pathlib.Path) -> None: """Divergent: ours deleted, theirs modified → theirs strategy keeps their modification.""" r = self._m(tmp_path, _OLD, None, _NEW) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "theirs" def test_ours_modified_theirs_deleted_theirs_wins(self, tmp_path: pathlib.Path) -> None: """Divergent: ours modified, theirs deleted → theirs strategy deletes the file.""" r = self._m(tmp_path, _OLD, _NEW, None) assert r.is_clean assert PATH not in _files(r) assert r.applied_strategies.get(PATH) == "theirs" class TestBasePermutations: """Strategy: base — revert to common ancestor when divergent.""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("base")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean and _files(r).get(PATH) == _OLD def test_convergent(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _OLD) assert r.is_clean and _files(r).get(PATH) == _NEW def test_theirs_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_reverts_to_base(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _OLD assert r.applied_strategies.get(PATH) == "base" def test_both_deleted(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, None) assert r.is_clean and PATH not in _files(r) def test_both_added_same(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_both_added_different_reverts_to_absent(self, tmp_path: pathlib.Path) -> None: """b=None, both added different → base is absent → file removed.""" r = self._m(tmp_path, None, _NEW, _ALT) assert r.is_clean assert PATH not in _files(r) assert r.applied_strategies.get(PATH) == "base" def test_only_ours_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, None) assert r.is_clean and _files(r).get(PATH) == _NEW def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, None, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_deletion_conflict_reverts_to_base(self, tmp_path: pathlib.Path) -> None: """Ours deleted, theirs modified → base keeps original file.""" r = self._m(tmp_path, _OLD, None, _NEW) assert r.is_clean assert _files(r).get(PATH) == _OLD assert r.applied_strategies.get(PATH) == "base" class TestUnionPermutations: """Strategy: union — keep additions from both sides; prefer left for blob conflicts.""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("union")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean and _files(r).get(PATH) == _OLD def test_convergent(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _OLD) assert r.is_clean and _files(r).get(PATH) == _NEW def test_theirs_only(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_prefers_left_for_blobs(self, tmp_path: pathlib.Path) -> None: """File-level blobs can't be truly unioned — prefers left.""" r = self._m(tmp_path, _OLD, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "union" def test_both_deleted(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, None) assert r.is_clean and PATH not in _files(r) def test_both_added_same(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_both_added_different_prefers_ours(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _ALT) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "union" def test_only_ours_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, None) assert r.is_clean and _files(r).get(PATH) == _NEW def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, None, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_deleted_theirs_modified_keeps_theirs(self, tmp_path: pathlib.Path) -> None: """Union: ours deleted, theirs modified → keep the addition (theirs).""" r = self._m(tmp_path, _OLD, None, _NEW) assert r.is_clean assert _files(r).get(PATH) == _NEW assert r.applied_strategies.get(PATH) == "union" class TestManualPermutations: """Strategy: manual — force conflict on any single-branch change; never conflict when both sides agree (l == r).""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("manual")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged_no_conflict(self, tmp_path: pathlib.Path) -> None: """b==l==r: nothing changed — manual must NOT fire.""" r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean assert PATH not in r.conflicts def test_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """b!=l==r: both agree on new value — manual must NOT fire.""" r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean assert PATH not in r.conflicts def test_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None: """l==r==None: both deleted — that's agreement, manual must NOT fire.""" r = self._m(tmp_path, _OLD, None, None) assert r.is_clean assert PATH not in r.conflicts def test_both_added_same_no_conflict(self, tmp_path: pathlib.Path) -> None: """b=None, l==r: both added same content — no conflict.""" r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean assert PATH not in r.conflicts def test_ours_only_forces_conflict(self, tmp_path: pathlib.Path) -> None: """Only ours changed → manual forces human review.""" r = self._m(tmp_path, _OLD, _NEW, _OLD) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" def test_theirs_only_forces_conflict(self, tmp_path: pathlib.Path) -> None: """Only theirs changed → manual forces human review.""" r = self._m(tmp_path, _OLD, _OLD, _NEW) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" def test_only_ours_added_forces_conflict(self, tmp_path: pathlib.Path) -> None: """b=None, only ours added → manual forces review.""" r = self._m(tmp_path, None, _NEW, None) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" def test_only_theirs_added_forces_conflict(self, tmp_path: pathlib.Path) -> None: """b=None, only theirs added → manual forces review.""" r = self._m(tmp_path, None, None, _NEW) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" def test_divergent_forces_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _ALT) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" def test_ours_deleted_theirs_modified_forces_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, _NEW) assert PATH in r.conflicts def test_ours_modified_theirs_deleted_forces_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, None) assert PATH in r.conflicts def test_both_added_different_forces_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _ALT) assert PATH in r.conflicts assert r.applied_strategies.get(PATH) == "manual" class TestAutoPermutations: """Strategy: auto (default) — standard three-way; conflict only when divergent.""" def _m(self, tmp_path: pathlib.Path, base_h: str | None, ours_h: str | None, theirs_h: str | None) -> MergeResult: _write_attrs(tmp_path, _rule("auto")) b = _snap((PATH, base_h)) if base_h else _snap() o = _snap((PATH, ours_h)) if ours_h else _snap() t = _snap((PATH, theirs_h)) if theirs_h else _snap() return _merge(b, o, t, tmp_path) def test_unchanged(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _OLD) assert r.is_clean and _files(r).get(PATH) == _OLD def test_convergent(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_only_no_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _OLD) assert r.is_clean and _files(r).get(PATH) == _NEW def test_theirs_only_no_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _OLD, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_divergent_conflicts(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, _ALT) assert PATH in r.conflicts assert PATH not in r.applied_strategies # auto is the silent default def test_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, None) assert r.is_clean and PATH not in _files(r) def test_both_added_same_no_conflict(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_both_added_different_conflicts(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, _ALT) assert PATH in r.conflicts def test_only_ours_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, _NEW, None) assert r.is_clean and _files(r).get(PATH) == _NEW def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, None, None, _NEW) assert r.is_clean and _files(r).get(PATH) == _NEW def test_ours_deleted_theirs_modified_conflicts(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, None, _NEW) assert PATH in r.conflicts def test_ours_modified_theirs_deleted_conflicts(self, tmp_path: pathlib.Path) -> None: r = self._m(tmp_path, _OLD, _NEW, None) assert PATH in r.conflicts # =========================================================================== # Validation of the actual .museattributes rules in this repository # # Each test validates one rule from .museattributes: # 1. muse/core/** manual priority=100 # 2. docs/** union priority=50 # 3. tests/** union priority=40 (dimension=symbols at OT level) # 4. muse/cli/commands/** union priority=30 (dimension=imports at OT level) # 5. pyproject.toml manual priority=20 (was "ours" — changed to manual) # 6. *.md union priority=10 # =========================================================================== _MUSE_ATTRS = """\ [meta] domain = "code" [[rules]] path = "muse/core/**" dimension = "*" strategy = "manual" comment = "Core store and object model — always needs a human eye on merge." priority = 100 [[rules]] path = "docs/**" dimension = "*" strategy = "union" comment = "Documentation additions from both branches are always welcome." priority = 50 [[rules]] path = "tests/**" dimension = "symbols" strategy = "union" comment = "Test additions from both branches are safe to accumulate." priority = 40 [[rules]] path = "muse/cli/commands/**" dimension = "imports" strategy = "union" comment = "Import sets in command modules are independent — accumulate both sides." priority = 30 [[rules]] path = "pyproject.toml" dimension = "*" strategy = "manual" comment = "Project config needs human review — never silently discard dep changes." priority = 20 [[rules]] path = "*.md" dimension = "*" strategy = "union" comment = "Markdown docs — union keeps prose additions from both branches." priority = 10 """ class TestMuseAttributesRules: """Validate every rule in the repo's .museattributes is correct and sensible.""" def _write(self, root: pathlib.Path) -> None: _write_attrs(root, _MUSE_ATTRS) # ------------------------------------------------------------------ # Rule 1: muse/core/** = manual, priority=100 # ------------------------------------------------------------------ def test_core_unchanged_no_false_conflict(self, tmp_path: pathlib.Path) -> None: """Unchanged core files MUST NOT produce conflicts (our regression fix).""" self._write(tmp_path) snap = _snap( ("muse/core/store.py", _OLD), ("muse/core/__init__.py", _OLD), ("muse/core/merge_engine.py", _OLD), ) r = _merge(snap, snap, snap, tmp_path) assert r.is_clean, f"False conflicts: {r.conflicts}" def test_core_single_branch_change_forces_conflict(self, tmp_path: pathlib.Path) -> None: """Any change to muse/core/** by one branch must be flagged for review.""" self._write(tmp_path) base = _snap(("muse/core/store.py", _OLD)) ours = _snap(("muse/core/store.py", _NEW)) theirs = _snap(("muse/core/store.py", _OLD)) r = _merge(base, ours, theirs, tmp_path) assert "muse/core/store.py" in r.conflicts def test_core_divergent_conflict(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) base = _snap(("muse/core/store.py", _OLD)) ours = _snap(("muse/core/store.py", _NEW)) theirs = _snap(("muse/core/store.py", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert "muse/core/store.py" in r.conflicts def test_core_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None: """Both branches independently made the same fix → no conflict.""" self._write(tmp_path) base = _snap(("muse/core/store.py", _OLD)) same = _snap(("muse/core/store.py", _NEW)) r = _merge(base, same, same, tmp_path) assert r.is_clean def test_core_rule_beats_lower_priority_wildcard(self, tmp_path: pathlib.Path) -> None: """muse/core/**=manual (p=100) wins over any hypothetical catch-all.""" self._write(tmp_path) # Any file under muse/core/ gets manual, not auto base = _snap(("muse/core/new_module.py", _OLD)) ours = _snap(("muse/core/new_module.py", _NEW)) theirs = _snap(("muse/core/new_module.py", _OLD)) r = _merge(base, ours, theirs, tmp_path) assert r.applied_strategies.get("muse/core/new_module.py") == "manual" # ------------------------------------------------------------------ # Rule 2: docs/** = union, priority=50 # ------------------------------------------------------------------ def test_docs_disjoint_additions_both_kept(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) base = _snap() ours = _snap(("docs/api.md", _NEW)) theirs = _snap(("docs/guide.md", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert "docs/api.md" in _files(r) assert "docs/guide.md" in _files(r) def test_docs_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None: """Union on blob conflict: prefers left (ours), no conflict raised.""" self._write(tmp_path) base = _snap(("docs/readme.md", _OLD)) ours = _snap(("docs/readme.md", _NEW)) theirs = _snap(("docs/readme.md", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert _files(r)["docs/readme.md"] == _NEW assert r.applied_strategies.get("docs/readme.md") == "union" # ------------------------------------------------------------------ # Rule 3: tests/** = union (at file level, dimension ignored when * passed) # ------------------------------------------------------------------ def test_tests_disjoint_files_both_kept(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) base = _snap() ours = _snap(("tests/test_foo.py", _NEW)) theirs = _snap(("tests/test_bar.py", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert "tests/test_foo.py" in _files(r) assert "tests/test_bar.py" in _files(r) def test_tests_same_file_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None: """When both branches modify the same test file divergently, union picks ours.""" self._write(tmp_path) base = _snap(("tests/test_merge.py", _OLD)) ours = _snap(("tests/test_merge.py", _NEW)) theirs = _snap(("tests/test_merge.py", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert _files(r)["tests/test_merge.py"] == _NEW assert r.applied_strategies.get("tests/test_merge.py") == "union" # ------------------------------------------------------------------ # Rule 4: muse/cli/commands/** = union (at file level) # ------------------------------------------------------------------ def test_commands_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) base = _snap(("muse/cli/commands/merge.py", _OLD)) ours = _snap(("muse/cli/commands/merge.py", _NEW)) theirs = _snap(("muse/cli/commands/merge.py", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert _files(r)["muse/cli/commands/merge.py"] == _NEW assert r.applied_strategies.get("muse/cli/commands/merge.py") == "union" # ------------------------------------------------------------------ # Rule 5: pyproject.toml = manual, priority=20 # (was "ours" — that silently discarded incoming dependency changes) # ------------------------------------------------------------------ def test_pyproject_unchanged_no_conflict(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) snap = _snap(("pyproject.toml", _OLD)) r = _merge(snap, snap, snap, tmp_path) assert r.is_clean def test_pyproject_single_branch_change_forces_conflict(self, tmp_path: pathlib.Path) -> None: """A feature branch adding a dependency must NOT be silently discarded.""" self._write(tmp_path) base = _snap(("pyproject.toml", _OLD)) ours = _snap(("pyproject.toml", _OLD)) # dev unchanged theirs = _snap(("pyproject.toml", _NEW)) # feature added a dep r = _merge(base, ours, theirs, tmp_path) # With manual: forced conflict, human reviews whether to accept the dep assert "pyproject.toml" in r.conflicts assert r.applied_strategies.get("pyproject.toml") == "manual" def test_pyproject_divergent_forces_conflict(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path) base = _snap(("pyproject.toml", _OLD)) ours = _snap(("pyproject.toml", _NEW)) theirs = _snap(("pyproject.toml", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert "pyproject.toml" in r.conflicts # ------------------------------------------------------------------ # Rule 6: *.md = union, priority=10 # ------------------------------------------------------------------ def test_root_md_union(self, tmp_path: pathlib.Path) -> None: """Root-level *.md files get union treatment.""" self._write(tmp_path) base = _snap(("CHANGELOG.md", _OLD)) ours = _snap(("CHANGELOG.md", _NEW)) theirs = _snap(("CHANGELOG.md", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert r.is_clean assert r.applied_strategies.get("CHANGELOG.md") == "union" def test_nested_md_NOT_matched_by_root_glob(self, tmp_path: pathlib.Path) -> None: """docs/api.md matches docs/** (p=50) not *.md (p=10) — docs rule wins.""" self._write(tmp_path) base = _snap(("docs/api.md", _OLD)) ours = _snap(("docs/api.md", _NEW)) theirs = _snap(("docs/api.md", _ALT)) r = _merge(base, ours, theirs, tmp_path) # Both docs/** and *.md are union — result is union either way. # Key: docs/** (p=50) fires first, not *.md (p=10). assert r.applied_strategies.get("docs/api.md") == "union" # ------------------------------------------------------------------ # Priority interactions # ------------------------------------------------------------------ def test_core_manual_beats_any_other_rule(self, tmp_path: pathlib.Path) -> None: """muse/core/** (p=100) must win over every other active rule.""" self._write(tmp_path) # This file could hypothetically match docs/** if it were there, # but muse/core/ files must always get manual. base = _snap(("muse/core/store.py", _OLD)) ours = _snap(("muse/core/store.py", _NEW)) theirs = _snap(("muse/core/store.py", _OLD)) r = _merge(base, ours, theirs, tmp_path) assert r.applied_strategies.get("muse/core/store.py") == "manual" def test_unmatched_path_gets_auto(self, tmp_path: pathlib.Path) -> None: """Files not matching any rule fall back to auto (standard conflict).""" self._write(tmp_path) base = _snap(("some_random_file.py", _OLD)) ours = _snap(("some_random_file.py", _NEW)) theirs = _snap(("some_random_file.py", _ALT)) r = _merge(base, ours, theirs, tmp_path) assert "some_random_file.py" in r.conflicts assert "some_random_file.py" not in r.applied_strategies