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