cgcardona / muse public
test_code_plugin_attributes.py python
318 lines 11.3 KB
12559ad7 feat: supercharge .museattributes — base/union strategies, priority, co… Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Integration tests: .museattributes × CodePlugin.merge()
2
3 Verifies that every merge strategy (ours, theirs, base, union, manual, auto)
4 is correctly honoured by CodePlugin.merge() and merge_ops() when a
5 .museattributes file is present in the repo root.
6 """
7 from __future__ import annotations
8
9 import pathlib
10
11 import pytest
12
13 from muse.core.attributes import AttributeRule
14 from muse.domain import MergeResult, SnapshotManifest
15 from muse.plugins.code.plugin import CodePlugin
16
17
18 # ---------------------------------------------------------------------------
19 # Fixtures
20 # ---------------------------------------------------------------------------
21
22 plugin = CodePlugin()
23
24 _A_HASH = "aaa" * 21 + "aa" # 64-char placeholder SHA-256
25 _B_HASH = "bbb" * 21 + "bb"
26 _C_HASH = "ccc" * 21 + "cc"
27 _D_HASH = "ddd" * 21 + "dd"
28
29
30 def _snap(*pairs: tuple[str, str]) -> SnapshotManifest:
31 return SnapshotManifest(files=dict(pairs), domain="code")
32
33
34 def _write_attrs(root: pathlib.Path, content: str) -> None:
35 (root / ".museattributes").write_text(content, encoding="utf-8")
36
37
38 # ---------------------------------------------------------------------------
39 # Strategy: ours
40 # ---------------------------------------------------------------------------
41
42
43 class TestOursStrategy:
44 def test_ours_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None:
45 _write_attrs(
46 tmp_path,
47 '[[rules]]\npath = "src/utils.py"\ndimension = "*"\nstrategy = "ours"\n',
48 )
49 base = _snap(("src/utils.py", _A_HASH))
50 left = _snap(("src/utils.py", _B_HASH)) # left changed
51 right = _snap(("src/utils.py", _C_HASH)) # right changed
52
53 result = plugin.merge(base, left, right, repo_root=tmp_path)
54
55 assert result.conflicts == []
56 assert result.merged["files"]["src/utils.py"] == _B_HASH
57 assert result.applied_strategies["src/utils.py"] == "ours"
58
59 def test_ours_glob_resolves_multiple_files(self, tmp_path: pathlib.Path) -> None:
60 _write_attrs(
61 tmp_path,
62 '[[rules]]\npath = "src/**"\ndimension = "*"\nstrategy = "ours"\n',
63 )
64 base = _snap(("src/a.py", _A_HASH), ("src/b.py", _A_HASH))
65 left = _snap(("src/a.py", _B_HASH), ("src/b.py", _B_HASH))
66 right = _snap(("src/a.py", _C_HASH), ("src/b.py", _C_HASH))
67
68 result = plugin.merge(base, left, right, repo_root=tmp_path)
69
70 assert result.conflicts == []
71 assert result.merged["files"]["src/a.py"] == _B_HASH
72 assert result.merged["files"]["src/b.py"] == _B_HASH
73 assert result.applied_strategies["src/a.py"] == "ours"
74
75
76 # ---------------------------------------------------------------------------
77 # Strategy: theirs
78 # ---------------------------------------------------------------------------
79
80
81 class TestTheirsStrategy:
82 def test_theirs_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None:
83 _write_attrs(
84 tmp_path,
85 '[[rules]]\npath = "config.toml"\ndimension = "*"\nstrategy = "theirs"\n',
86 )
87 base = _snap(("config.toml", _A_HASH))
88 left = _snap(("config.toml", _B_HASH))
89 right = _snap(("config.toml", _C_HASH))
90
91 result = plugin.merge(base, left, right, repo_root=tmp_path)
92
93 assert result.conflicts == []
94 assert result.merged["files"]["config.toml"] == _C_HASH
95 assert result.applied_strategies["config.toml"] == "theirs"
96
97
98 # ---------------------------------------------------------------------------
99 # Strategy: base
100 # ---------------------------------------------------------------------------
101
102
103 class TestBaseStrategy:
104 def test_base_reverts_both_branch_changes(self, tmp_path: pathlib.Path) -> None:
105 _write_attrs(
106 tmp_path,
107 '[[rules]]\npath = "lock.json"\ndimension = "*"\nstrategy = "base"\n',
108 )
109 base = _snap(("lock.json", _A_HASH))
110 left = _snap(("lock.json", _B_HASH))
111 right = _snap(("lock.json", _C_HASH))
112
113 result = plugin.merge(base, left, right, repo_root=tmp_path)
114
115 assert result.conflicts == []
116 assert result.merged["files"]["lock.json"] == _A_HASH
117 assert result.applied_strategies["lock.json"] == "base"
118
119 def test_base_removes_file_when_base_deleted_it(self, tmp_path: pathlib.Path) -> None:
120 """base strategy on a file absent in base removes it from merge."""
121 _write_attrs(
122 tmp_path,
123 '[[rules]]\npath = "generated.py"\ndimension = "*"\nstrategy = "base"\n',
124 )
125 # File was absent in base, added by both sides differently.
126 base = _snap()
127 left = _snap(("generated.py", _B_HASH))
128 right = _snap(("generated.py", _C_HASH))
129
130 result = plugin.merge(base, left, right, repo_root=tmp_path)
131
132 assert result.conflicts == []
133 assert "generated.py" not in result.merged["files"]
134 assert result.applied_strategies["generated.py"] == "base"
135
136
137 # ---------------------------------------------------------------------------
138 # Strategy: union
139 # ---------------------------------------------------------------------------
140
141
142 class TestUnionStrategy:
143 def test_union_keeps_left_for_binary_blob_conflict(
144 self, tmp_path: pathlib.Path
145 ) -> None:
146 _write_attrs(
147 tmp_path,
148 '[[rules]]\npath = "docs/*"\ndimension = "*"\nstrategy = "union"\n',
149 )
150 base = _snap(("docs/api.md", _A_HASH))
151 left = _snap(("docs/api.md", _B_HASH))
152 right = _snap(("docs/api.md", _C_HASH))
153
154 result = plugin.merge(base, left, right, repo_root=tmp_path)
155
156 assert result.conflicts == []
157 assert result.merged["files"]["docs/api.md"] == _B_HASH
158 assert result.applied_strategies["docs/api.md"] == "union"
159
160 def test_union_keeps_additions_from_both_sides(
161 self, tmp_path: pathlib.Path
162 ) -> None:
163 _write_attrs(
164 tmp_path,
165 '[[rules]]\npath = "tests/**"\ndimension = "*"\nstrategy = "union"\n',
166 )
167 base = _snap()
168 left = _snap(("tests/test_a.py", _A_HASH))
169 right = _snap(("tests/test_b.py", _B_HASH))
170
171 result = plugin.merge(base, left, right, repo_root=tmp_path)
172
173 # Both new files appear — neither is a conflict.
174 assert "tests/test_a.py" in result.merged["files"]
175 assert "tests/test_b.py" in result.merged["files"]
176 assert result.conflicts == []
177
178
179 # ---------------------------------------------------------------------------
180 # Strategy: manual
181 # ---------------------------------------------------------------------------
182
183
184 class TestManualStrategy:
185 def test_manual_forces_conflict_on_auto_resolved_path(
186 self, tmp_path: pathlib.Path
187 ) -> None:
188 _write_attrs(
189 tmp_path,
190 '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n',
191 )
192 # Only left changed — auto would resolve cleanly.
193 base = _snap(("src/core.py", _A_HASH))
194 left = _snap(("src/core.py", _B_HASH))
195 right = _snap(("src/core.py", _A_HASH)) # right unchanged
196
197 result = plugin.merge(base, left, right, repo_root=tmp_path)
198
199 assert "src/core.py" in result.conflicts
200 assert result.applied_strategies["src/core.py"] == "manual"
201
202 def test_manual_forces_conflict_on_bilateral_conflict(
203 self, tmp_path: pathlib.Path
204 ) -> None:
205 _write_attrs(
206 tmp_path,
207 '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n',
208 )
209 base = _snap(("src/core.py", _A_HASH))
210 left = _snap(("src/core.py", _B_HASH))
211 right = _snap(("src/core.py", _C_HASH))
212
213 result = plugin.merge(base, left, right, repo_root=tmp_path)
214
215 assert "src/core.py" in result.conflicts
216 assert result.applied_strategies["src/core.py"] == "manual"
217
218
219 # ---------------------------------------------------------------------------
220 # Strategy: auto (default)
221 # ---------------------------------------------------------------------------
222
223
224 class TestAutoStrategy:
225 def test_no_attrs_file_produces_standard_conflicts(
226 self, tmp_path: pathlib.Path
227 ) -> None:
228 base = _snap(("src/a.py", _A_HASH))
229 left = _snap(("src/a.py", _B_HASH))
230 right = _snap(("src/a.py", _C_HASH))
231
232 result = plugin.merge(base, left, right, repo_root=tmp_path)
233
234 assert "src/a.py" in result.conflicts
235 assert result.applied_strategies == {}
236
237 def test_auto_strategy_is_standard_conflict(self, tmp_path: pathlib.Path) -> None:
238 _write_attrs(
239 tmp_path,
240 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n',
241 )
242 base = _snap(("src/a.py", _A_HASH))
243 left = _snap(("src/a.py", _B_HASH))
244 right = _snap(("src/a.py", _C_HASH))
245
246 result = plugin.merge(base, left, right, repo_root=tmp_path)
247
248 assert "src/a.py" in result.conflicts
249 # "auto" never appears in applied_strategies — it's the silent default.
250 assert "src/a.py" not in result.applied_strategies
251
252
253 # ---------------------------------------------------------------------------
254 # Priority ordering
255 # ---------------------------------------------------------------------------
256
257
258 class TestPriorityInMerge:
259 def test_high_priority_rule_beats_catch_all(self, tmp_path: pathlib.Path) -> None:
260 _write_attrs(
261 tmp_path,
262 '[[rules]]\n'
263 'path = "*"\ndimension = "*"\nstrategy = "theirs"\npriority = 0\n\n'
264 '[[rules]]\n'
265 'path = "src/core.py"\ndimension = "*"\nstrategy = "ours"\npriority = 50\n',
266 )
267 base = _snap(("src/core.py", _A_HASH))
268 left = _snap(("src/core.py", _B_HASH))
269 right = _snap(("src/core.py", _C_HASH))
270
271 result = plugin.merge(base, left, right, repo_root=tmp_path)
272
273 # High-priority "ours" rule fires, not the catch-all "theirs".
274 assert result.merged["files"]["src/core.py"] == _B_HASH
275 assert result.applied_strategies["src/core.py"] == "ours"
276
277
278 # ---------------------------------------------------------------------------
279 # No repo_root — graceful degradation
280 # ---------------------------------------------------------------------------
281
282
283 class TestNoRepoRoot:
284 def test_merge_without_repo_root_ignores_attributes(self) -> None:
285 base = _snap(("a.py", _A_HASH))
286 left = _snap(("a.py", _B_HASH))
287 right = _snap(("a.py", _C_HASH))
288
289 result = plugin.merge(base, left, right, repo_root=None)
290
291 assert "a.py" in result.conflicts
292 assert result.applied_strategies == {}
293
294
295 # ---------------------------------------------------------------------------
296 # applied_strategies propagation through merge_ops
297 # ---------------------------------------------------------------------------
298
299
300 class TestMergeOpsAttributePropagation:
301 def test_applied_strategies_flow_through_merge_ops(
302 self, tmp_path: pathlib.Path
303 ) -> None:
304 _write_attrs(
305 tmp_path,
306 '[[rules]]\npath = "src/a.py"\ndimension = "*"\nstrategy = "ours"\n',
307 )
308 base = _snap(("src/a.py", _A_HASH))
309 ours = _snap(("src/a.py", _B_HASH))
310 theirs = _snap(("src/a.py", _C_HASH))
311
312 result: MergeResult = plugin.merge_ops(
313 base, ours, theirs,
314 ours_ops=[], theirs_ops=[],
315 repo_root=tmp_path,
316 )
317
318 assert result.applied_strategies.get("src/a.py") == "ours"