gabriel / muse public
test_code_plugin_attributes.py python
1,031 lines 41.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days 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 Change scenarios tested for each strategy
8 ------------------------------------------
9 UNCHANGED b==l==r neither branch touched the file
10 CONVERGENT b!=l==r both branches made the same edit
11 OURS_ONLY b==r, l!=b only our branch changed it
12 THEIRS_ONLY b==l, r!=b only their branch changed it
13 DIVERGENT b!=l, b!=r, l!=r both changed differently
14 BOTH_DELETED b!=None, l==r==None both deleted
15 OURS_DEL_THEIRS_MOD l==None, r!=b ours deleted, theirs modified
16 OURS_MOD_THEIRS_DEL l!=b, r==None ours modified, theirs deleted
17 ONLY_OURS_ADDED b==None, l!=None, r==None ours added new file
18 ONLY_THEIRS_ADDED b==None, l==None, r!=None theirs added new file
19 BOTH_ADDED_SAME b==None, l==r!=None both added identical
20 BOTH_ADDED_DIFF b==None, l!=r (both non-None) both added different
21 """
22
23 import pathlib
24
25 import pytest
26
27 from muse.core.attributes import AttributeRule
28 from muse.domain import MergeResult, SnapshotManifest
29 from muse.plugins.code.plugin import CodePlugin
30
31
32 # ---------------------------------------------------------------------------
33 # Fixtures
34 # ---------------------------------------------------------------------------
35
36 plugin = CodePlugin()
37
38 _A_HASH = "aaa" * 21 + "aa" # 64-char placeholder SHA-256
39 _B_HASH = "bbb" * 21 + "bb"
40 _C_HASH = "ccc" * 21 + "cc"
41 _D_HASH = "ddd" * 21 + "dd"
42
43 # Readable aliases used in permutation comments
44 _OLD = _A_HASH
45 _NEW = _B_HASH
46 _ALT = _C_HASH
47
48 PATH = "src/target.py"
49
50 # TOML helpers
51 def _rule(strategy: str, path: str = PATH) -> str:
52 return f'[[rules]]\npath = "{path}"\ndimension = "*"\nstrategy = "{strategy}"\n'
53
54
55 def _snap(*pairs: tuple[str, str]) -> SnapshotManifest:
56 return SnapshotManifest(files=dict(pairs), domain="code")
57
58
59 def _write_attrs(root: pathlib.Path, content: str) -> None:
60 (root / ".museattributes").write_text(content, encoding="utf-8")
61
62
63 def _merge(
64 base: SnapshotManifest,
65 ours: SnapshotManifest,
66 theirs: SnapshotManifest,
67 root: pathlib.Path,
68 ) -> MergeResult:
69 return plugin.merge(base, ours, theirs, repo_root=root)
70
71
72 def _files(result: MergeResult) -> dict:
73 return result.merged["files"]
74
75
76 # ---------------------------------------------------------------------------
77 # Strategy: ours
78 # ---------------------------------------------------------------------------
79
80
81 class TestOursStrategy:
82 def test_ours_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None:
83 _write_attrs(
84 tmp_path,
85 '[[rules]]\npath = "src/utils.py"\ndimension = "*"\nstrategy = "ours"\n',
86 )
87 base = _snap(("src/utils.py", _A_HASH))
88 left = _snap(("src/utils.py", _B_HASH)) # left changed
89 right = _snap(("src/utils.py", _C_HASH)) # right changed
90
91 result = plugin.merge(base, left, right, repo_root=tmp_path)
92
93 assert result.conflicts == []
94 assert result.merged["files"]["src/utils.py"] == _B_HASH
95 assert result.applied_strategies["src/utils.py"] == "ours"
96
97 def test_ours_glob_resolves_multiple_files(self, tmp_path: pathlib.Path) -> None:
98 _write_attrs(
99 tmp_path,
100 '[[rules]]\npath = "src/**"\ndimension = "*"\nstrategy = "ours"\n',
101 )
102 base = _snap(("src/a.py", _A_HASH), ("src/b.py", _A_HASH))
103 left = _snap(("src/a.py", _B_HASH), ("src/b.py", _B_HASH))
104 right = _snap(("src/a.py", _C_HASH), ("src/b.py", _C_HASH))
105
106 result = plugin.merge(base, left, right, repo_root=tmp_path)
107
108 assert result.conflicts == []
109 assert result.merged["files"]["src/a.py"] == _B_HASH
110 assert result.merged["files"]["src/b.py"] == _B_HASH
111 assert result.applied_strategies["src/a.py"] == "ours"
112
113
114 # ---------------------------------------------------------------------------
115 # Strategy: theirs
116 # ---------------------------------------------------------------------------
117
118
119 class TestTheirsStrategy:
120 def test_theirs_resolves_bilateral_conflict(self, tmp_path: pathlib.Path) -> None:
121 _write_attrs(
122 tmp_path,
123 '[[rules]]\npath = "config.toml"\ndimension = "*"\nstrategy = "theirs"\n',
124 )
125 base = _snap(("config.toml", _A_HASH))
126 left = _snap(("config.toml", _B_HASH))
127 right = _snap(("config.toml", _C_HASH))
128
129 result = plugin.merge(base, left, right, repo_root=tmp_path)
130
131 assert result.conflicts == []
132 assert result.merged["files"]["config.toml"] == _C_HASH
133 assert result.applied_strategies["config.toml"] == "theirs"
134
135
136 # ---------------------------------------------------------------------------
137 # Strategy: base
138 # ---------------------------------------------------------------------------
139
140
141 class TestBaseStrategy:
142 def test_base_reverts_both_branch_changes(self, tmp_path: pathlib.Path) -> None:
143 _write_attrs(
144 tmp_path,
145 '[[rules]]\npath = "lock.json"\ndimension = "*"\nstrategy = "base"\n',
146 )
147 base = _snap(("lock.json", _A_HASH))
148 left = _snap(("lock.json", _B_HASH))
149 right = _snap(("lock.json", _C_HASH))
150
151 result = plugin.merge(base, left, right, repo_root=tmp_path)
152
153 assert result.conflicts == []
154 assert result.merged["files"]["lock.json"] == _A_HASH
155 assert result.applied_strategies["lock.json"] == "base"
156
157 def test_base_removes_file_when_base_deleted_it(self, tmp_path: pathlib.Path) -> None:
158 """base strategy on a file absent in base removes it from merge."""
159 _write_attrs(
160 tmp_path,
161 '[[rules]]\npath = "generated.py"\ndimension = "*"\nstrategy = "base"\n',
162 )
163 # File was absent in base, added by both sides differently.
164 base = _snap()
165 left = _snap(("generated.py", _B_HASH))
166 right = _snap(("generated.py", _C_HASH))
167
168 result = plugin.merge(base, left, right, repo_root=tmp_path)
169
170 assert result.conflicts == []
171 assert "generated.py" not in result.merged["files"]
172 assert result.applied_strategies["generated.py"] == "base"
173
174
175 # ---------------------------------------------------------------------------
176 # Strategy: union
177 # ---------------------------------------------------------------------------
178
179
180 class TestUnionStrategy:
181 def test_union_keeps_left_for_binary_blob_conflict(
182 self, tmp_path: pathlib.Path
183 ) -> None:
184 _write_attrs(
185 tmp_path,
186 '[[rules]]\npath = "docs/*"\ndimension = "*"\nstrategy = "union"\n',
187 )
188 base = _snap(("docs/api.md", _A_HASH))
189 left = _snap(("docs/api.md", _B_HASH))
190 right = _snap(("docs/api.md", _C_HASH))
191
192 result = plugin.merge(base, left, right, repo_root=tmp_path)
193
194 assert result.conflicts == []
195 assert result.merged["files"]["docs/api.md"] == _B_HASH
196 assert result.applied_strategies["docs/api.md"] == "union"
197
198 def test_union_keeps_additions_from_both_sides(
199 self, tmp_path: pathlib.Path
200 ) -> None:
201 _write_attrs(
202 tmp_path,
203 '[[rules]]\npath = "tests/**"\ndimension = "*"\nstrategy = "union"\n',
204 )
205 base = _snap()
206 left = _snap(("tests/test_a.py", _A_HASH))
207 right = _snap(("tests/test_b.py", _B_HASH))
208
209 result = plugin.merge(base, left, right, repo_root=tmp_path)
210
211 # Both new files appear — neither is a conflict.
212 assert "tests/test_a.py" in result.merged["files"]
213 assert "tests/test_b.py" in result.merged["files"]
214 assert result.conflicts == []
215
216
217 # ---------------------------------------------------------------------------
218 # Strategy: manual
219 # ---------------------------------------------------------------------------
220
221
222 class TestManualStrategy:
223 def test_manual_forces_conflict_on_auto_resolved_path(
224 self, tmp_path: pathlib.Path
225 ) -> None:
226 _write_attrs(
227 tmp_path,
228 '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n',
229 )
230 # Only left changed — auto would resolve cleanly.
231 base = _snap(("src/core.py", _A_HASH))
232 left = _snap(("src/core.py", _B_HASH))
233 right = _snap(("src/core.py", _A_HASH)) # right unchanged
234
235 result = plugin.merge(base, left, right, repo_root=tmp_path)
236
237 assert "src/core.py" in result.conflicts
238 assert result.applied_strategies["src/core.py"] == "manual"
239
240 def test_manual_forces_conflict_on_bilateral_conflict(
241 self, tmp_path: pathlib.Path
242 ) -> None:
243 _write_attrs(
244 tmp_path,
245 '[[rules]]\npath = "src/core.py"\ndimension = "*"\nstrategy = "manual"\n',
246 )
247 base = _snap(("src/core.py", _A_HASH))
248 left = _snap(("src/core.py", _B_HASH))
249 right = _snap(("src/core.py", _C_HASH))
250
251 result = plugin.merge(base, left, right, repo_root=tmp_path)
252
253 assert "src/core.py" in result.conflicts
254 assert result.applied_strategies["src/core.py"] == "manual"
255
256
257 # ---------------------------------------------------------------------------
258 # Strategy: auto (default)
259 # ---------------------------------------------------------------------------
260
261
262 class TestAutoStrategy:
263 def test_no_attrs_file_produces_standard_conflicts(
264 self, tmp_path: pathlib.Path
265 ) -> None:
266 base = _snap(("src/a.py", _A_HASH))
267 left = _snap(("src/a.py", _B_HASH))
268 right = _snap(("src/a.py", _C_HASH))
269
270 result = plugin.merge(base, left, right, repo_root=tmp_path)
271
272 assert "src/a.py" in result.conflicts
273 assert result.applied_strategies == {}
274
275 def test_auto_strategy_is_standard_conflict(self, tmp_path: pathlib.Path) -> None:
276 _write_attrs(
277 tmp_path,
278 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n',
279 )
280 base = _snap(("src/a.py", _A_HASH))
281 left = _snap(("src/a.py", _B_HASH))
282 right = _snap(("src/a.py", _C_HASH))
283
284 result = plugin.merge(base, left, right, repo_root=tmp_path)
285
286 assert "src/a.py" in result.conflicts
287 # "auto" never appears in applied_strategies — it's the silent default.
288 assert "src/a.py" not in result.applied_strategies
289
290
291 # ---------------------------------------------------------------------------
292 # Priority ordering
293 # ---------------------------------------------------------------------------
294
295
296 class TestPriorityInMerge:
297 def test_high_priority_rule_beats_catch_all(self, tmp_path: pathlib.Path) -> None:
298 _write_attrs(
299 tmp_path,
300 '[[rules]]\n'
301 'path = "*"\ndimension = "*"\nstrategy = "theirs"\npriority = 0\n\n'
302 '[[rules]]\n'
303 'path = "src/core.py"\ndimension = "*"\nstrategy = "ours"\npriority = 50\n',
304 )
305 base = _snap(("src/core.py", _A_HASH))
306 left = _snap(("src/core.py", _B_HASH))
307 right = _snap(("src/core.py", _C_HASH))
308
309 result = plugin.merge(base, left, right, repo_root=tmp_path)
310
311 # High-priority "ours" rule fires, not the catch-all "theirs".
312 assert result.merged["files"]["src/core.py"] == _B_HASH
313 assert result.applied_strategies["src/core.py"] == "ours"
314
315
316 # ---------------------------------------------------------------------------
317 # No repo_root — graceful degradation
318 # ---------------------------------------------------------------------------
319
320
321 class TestNoRepoRoot:
322 def test_merge_without_repo_root_ignores_attributes(self) -> None:
323 base = _snap(("a.py", _A_HASH))
324 left = _snap(("a.py", _B_HASH))
325 right = _snap(("a.py", _C_HASH))
326
327 result = plugin.merge(base, left, right, repo_root=None)
328
329 assert "a.py" in result.conflicts
330 assert result.applied_strategies == {}
331
332
333 # ---------------------------------------------------------------------------
334 # applied_strategies propagation through merge_ops
335 # ---------------------------------------------------------------------------
336
337
338 class TestMergeOpsAttributePropagation:
339 def test_applied_strategies_flow_through_merge_ops(
340 self, tmp_path: pathlib.Path
341 ) -> None:
342 _write_attrs(
343 tmp_path,
344 '[[rules]]\npath = "src/a.py"\ndimension = "*"\nstrategy = "ours"\n',
345 )
346 base = _snap(("src/a.py", _A_HASH))
347 ours = _snap(("src/a.py", _B_HASH))
348 theirs = _snap(("src/a.py", _C_HASH))
349
350 result: MergeResult = plugin.merge_ops(
351 base, ours, theirs,
352 ours_ops=[], theirs_ops=[],
353 repo_root=tmp_path,
354 )
355
356 assert result.applied_strategies.get("src/a.py") == "ours"
357
358
359 # ===========================================================================
360 # Comprehensive permutation matrix: every strategy × every change scenario
361 # ===========================================================================
362 #
363 # Each class tests one strategy across all 12 change scenarios.
364 # Scenarios where both sides agree (l == r) never produce a conflict,
365 # regardless of strategy. "manual" is the only strategy that fires on
366 # single-branch changes; all others let those through cleanly.
367 # ===========================================================================
368
369
370 class TestOursPermutations:
371 """Strategy: ours — take our (left) version when divergent."""
372
373 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
374 _write_attrs(tmp_path, _rule("ours"))
375 b = _snap((PATH, base_h)) if base_h else _snap()
376 o = _snap((PATH, ours_h)) if ours_h else _snap()
377 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
378 return _merge(b, o, t, tmp_path)
379
380 def test_unchanged(self, tmp_path: pathlib.Path) -> None:
381 r = self._m(tmp_path, _OLD, _OLD, _OLD)
382 assert r.is_clean and _files(r).get(PATH) == _OLD
383
384 def test_convergent(self, tmp_path: pathlib.Path) -> None:
385 r = self._m(tmp_path, _OLD, _NEW, _NEW)
386 assert r.is_clean and _files(r).get(PATH) == _NEW
387
388 def test_ours_only(self, tmp_path: pathlib.Path) -> None:
389 """Single-branch change (ours): ours does NOT force conflict."""
390 r = self._m(tmp_path, _OLD, _NEW, _OLD)
391 assert r.is_clean and _files(r).get(PATH) == _NEW
392
393 def test_theirs_only(self, tmp_path: pathlib.Path) -> None:
394 r = self._m(tmp_path, _OLD, _OLD, _NEW)
395 assert r.is_clean and _files(r).get(PATH) == _NEW
396
397 def test_divergent_takes_ours(self, tmp_path: pathlib.Path) -> None:
398 r = self._m(tmp_path, _OLD, _NEW, _ALT)
399 assert r.is_clean
400 assert _files(r).get(PATH) == _NEW
401 assert r.applied_strategies.get(PATH) == "ours"
402
403 def test_both_deleted(self, tmp_path: pathlib.Path) -> None:
404 r = self._m(tmp_path, _OLD, None, None)
405 assert r.is_clean and PATH not in _files(r)
406
407 def test_both_added_same(self, tmp_path: pathlib.Path) -> None:
408 r = self._m(tmp_path, None, _NEW, _NEW)
409 assert r.is_clean and _files(r).get(PATH) == _NEW
410
411 def test_both_added_different_takes_ours(self, tmp_path: pathlib.Path) -> None:
412 r = self._m(tmp_path, None, _NEW, _ALT)
413 assert r.is_clean
414 assert _files(r).get(PATH) == _NEW
415 assert r.applied_strategies.get(PATH) == "ours"
416
417 def test_only_ours_added(self, tmp_path: pathlib.Path) -> None:
418 r = self._m(tmp_path, None, _NEW, None)
419 assert r.is_clean and _files(r).get(PATH) == _NEW
420
421 def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None:
422 r = self._m(tmp_path, None, None, _NEW)
423 assert r.is_clean and _files(r).get(PATH) == _NEW
424
425 def test_ours_modified_theirs_deleted_takes_ours(self, tmp_path: pathlib.Path) -> None:
426 """Divergent: ours modified, theirs deleted → ours strategy keeps ours modification."""
427 r = self._m(tmp_path, _OLD, _NEW, None)
428 assert r.is_clean
429 assert _files(r).get(PATH) == _NEW
430 assert r.applied_strategies.get(PATH) == "ours"
431
432 def test_ours_deleted_theirs_modified_ours_wins(self, tmp_path: pathlib.Path) -> None:
433 """Divergent: ours deleted, theirs modified → ours strategy deletes the file."""
434 r = self._m(tmp_path, _OLD, None, _NEW)
435 assert r.is_clean
436 assert PATH not in _files(r)
437 assert r.applied_strategies.get(PATH) == "ours"
438
439
440 class TestTheirsPermutations:
441 """Strategy: theirs — take their (right) version when divergent."""
442
443 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
444 _write_attrs(tmp_path, _rule("theirs"))
445 b = _snap((PATH, base_h)) if base_h else _snap()
446 o = _snap((PATH, ours_h)) if ours_h else _snap()
447 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
448 return _merge(b, o, t, tmp_path)
449
450 def test_unchanged(self, tmp_path: pathlib.Path) -> None:
451 r = self._m(tmp_path, _OLD, _OLD, _OLD)
452 assert r.is_clean and _files(r).get(PATH) == _OLD
453
454 def test_convergent(self, tmp_path: pathlib.Path) -> None:
455 r = self._m(tmp_path, _OLD, _NEW, _NEW)
456 assert r.is_clean and _files(r).get(PATH) == _NEW
457
458 def test_ours_only(self, tmp_path: pathlib.Path) -> None:
459 r = self._m(tmp_path, _OLD, _NEW, _OLD)
460 assert r.is_clean and _files(r).get(PATH) == _NEW
461
462 def test_theirs_only(self, tmp_path: pathlib.Path) -> None:
463 r = self._m(tmp_path, _OLD, _OLD, _NEW)
464 assert r.is_clean and _files(r).get(PATH) == _NEW
465
466 def test_divergent_takes_theirs(self, tmp_path: pathlib.Path) -> None:
467 r = self._m(tmp_path, _OLD, _NEW, _ALT)
468 assert r.is_clean
469 assert _files(r).get(PATH) == _ALT
470 assert r.applied_strategies.get(PATH) == "theirs"
471
472 def test_both_deleted(self, tmp_path: pathlib.Path) -> None:
473 r = self._m(tmp_path, _OLD, None, None)
474 assert r.is_clean and PATH not in _files(r)
475
476 def test_both_added_same(self, tmp_path: pathlib.Path) -> None:
477 r = self._m(tmp_path, None, _NEW, _NEW)
478 assert r.is_clean and _files(r).get(PATH) == _NEW
479
480 def test_both_added_different_takes_theirs(self, tmp_path: pathlib.Path) -> None:
481 r = self._m(tmp_path, None, _NEW, _ALT)
482 assert r.is_clean
483 assert _files(r).get(PATH) == _ALT
484 assert r.applied_strategies.get(PATH) == "theirs"
485
486 def test_only_ours_added(self, tmp_path: pathlib.Path) -> None:
487 r = self._m(tmp_path, None, _NEW, None)
488 assert r.is_clean and _files(r).get(PATH) == _NEW
489
490 def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None:
491 r = self._m(tmp_path, None, None, _NEW)
492 assert r.is_clean and _files(r).get(PATH) == _NEW
493
494 def test_ours_deleted_theirs_modified_takes_theirs(self, tmp_path: pathlib.Path) -> None:
495 """Divergent: ours deleted, theirs modified → theirs strategy keeps their modification."""
496 r = self._m(tmp_path, _OLD, None, _NEW)
497 assert r.is_clean
498 assert _files(r).get(PATH) == _NEW
499 assert r.applied_strategies.get(PATH) == "theirs"
500
501 def test_ours_modified_theirs_deleted_theirs_wins(self, tmp_path: pathlib.Path) -> None:
502 """Divergent: ours modified, theirs deleted → theirs strategy deletes the file."""
503 r = self._m(tmp_path, _OLD, _NEW, None)
504 assert r.is_clean
505 assert PATH not in _files(r)
506 assert r.applied_strategies.get(PATH) == "theirs"
507
508
509 class TestBasePermutations:
510 """Strategy: base — revert to common ancestor when divergent."""
511
512 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
513 _write_attrs(tmp_path, _rule("base"))
514 b = _snap((PATH, base_h)) if base_h else _snap()
515 o = _snap((PATH, ours_h)) if ours_h else _snap()
516 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
517 return _merge(b, o, t, tmp_path)
518
519 def test_unchanged(self, tmp_path: pathlib.Path) -> None:
520 r = self._m(tmp_path, _OLD, _OLD, _OLD)
521 assert r.is_clean and _files(r).get(PATH) == _OLD
522
523 def test_convergent(self, tmp_path: pathlib.Path) -> None:
524 r = self._m(tmp_path, _OLD, _NEW, _NEW)
525 assert r.is_clean and _files(r).get(PATH) == _NEW
526
527 def test_ours_only(self, tmp_path: pathlib.Path) -> None:
528 r = self._m(tmp_path, _OLD, _NEW, _OLD)
529 assert r.is_clean and _files(r).get(PATH) == _NEW
530
531 def test_theirs_only(self, tmp_path: pathlib.Path) -> None:
532 r = self._m(tmp_path, _OLD, _OLD, _NEW)
533 assert r.is_clean and _files(r).get(PATH) == _NEW
534
535 def test_divergent_reverts_to_base(self, tmp_path: pathlib.Path) -> None:
536 r = self._m(tmp_path, _OLD, _NEW, _ALT)
537 assert r.is_clean
538 assert _files(r).get(PATH) == _OLD
539 assert r.applied_strategies.get(PATH) == "base"
540
541 def test_both_deleted(self, tmp_path: pathlib.Path) -> None:
542 r = self._m(tmp_path, _OLD, None, None)
543 assert r.is_clean and PATH not in _files(r)
544
545 def test_both_added_same(self, tmp_path: pathlib.Path) -> None:
546 r = self._m(tmp_path, None, _NEW, _NEW)
547 assert r.is_clean and _files(r).get(PATH) == _NEW
548
549 def test_both_added_different_reverts_to_absent(self, tmp_path: pathlib.Path) -> None:
550 """b=None, both added different → base is absent → file removed."""
551 r = self._m(tmp_path, None, _NEW, _ALT)
552 assert r.is_clean
553 assert PATH not in _files(r)
554 assert r.applied_strategies.get(PATH) == "base"
555
556 def test_only_ours_added(self, tmp_path: pathlib.Path) -> None:
557 r = self._m(tmp_path, None, _NEW, None)
558 assert r.is_clean and _files(r).get(PATH) == _NEW
559
560 def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None:
561 r = self._m(tmp_path, None, None, _NEW)
562 assert r.is_clean and _files(r).get(PATH) == _NEW
563
564 def test_divergent_deletion_conflict_reverts_to_base(self, tmp_path: pathlib.Path) -> None:
565 """Ours deleted, theirs modified → base keeps original file."""
566 r = self._m(tmp_path, _OLD, None, _NEW)
567 assert r.is_clean
568 assert _files(r).get(PATH) == _OLD
569 assert r.applied_strategies.get(PATH) == "base"
570
571
572 class TestUnionPermutations:
573 """Strategy: union — keep additions from both sides; prefer left for blob conflicts."""
574
575 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
576 _write_attrs(tmp_path, _rule("union"))
577 b = _snap((PATH, base_h)) if base_h else _snap()
578 o = _snap((PATH, ours_h)) if ours_h else _snap()
579 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
580 return _merge(b, o, t, tmp_path)
581
582 def test_unchanged(self, tmp_path: pathlib.Path) -> None:
583 r = self._m(tmp_path, _OLD, _OLD, _OLD)
584 assert r.is_clean and _files(r).get(PATH) == _OLD
585
586 def test_convergent(self, tmp_path: pathlib.Path) -> None:
587 r = self._m(tmp_path, _OLD, _NEW, _NEW)
588 assert r.is_clean and _files(r).get(PATH) == _NEW
589
590 def test_ours_only(self, tmp_path: pathlib.Path) -> None:
591 r = self._m(tmp_path, _OLD, _NEW, _OLD)
592 assert r.is_clean and _files(r).get(PATH) == _NEW
593
594 def test_theirs_only(self, tmp_path: pathlib.Path) -> None:
595 r = self._m(tmp_path, _OLD, _OLD, _NEW)
596 assert r.is_clean and _files(r).get(PATH) == _NEW
597
598 def test_divergent_prefers_left_for_blobs(self, tmp_path: pathlib.Path) -> None:
599 """File-level blobs can't be truly unioned — prefers left."""
600 r = self._m(tmp_path, _OLD, _NEW, _ALT)
601 assert r.is_clean
602 assert _files(r).get(PATH) == _NEW
603 assert r.applied_strategies.get(PATH) == "union"
604
605 def test_both_deleted(self, tmp_path: pathlib.Path) -> None:
606 r = self._m(tmp_path, _OLD, None, None)
607 assert r.is_clean and PATH not in _files(r)
608
609 def test_both_added_same(self, tmp_path: pathlib.Path) -> None:
610 r = self._m(tmp_path, None, _NEW, _NEW)
611 assert r.is_clean and _files(r).get(PATH) == _NEW
612
613 def test_both_added_different_prefers_ours(self, tmp_path: pathlib.Path) -> None:
614 r = self._m(tmp_path, None, _NEW, _ALT)
615 assert r.is_clean
616 assert _files(r).get(PATH) == _NEW
617 assert r.applied_strategies.get(PATH) == "union"
618
619 def test_only_ours_added(self, tmp_path: pathlib.Path) -> None:
620 r = self._m(tmp_path, None, _NEW, None)
621 assert r.is_clean and _files(r).get(PATH) == _NEW
622
623 def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None:
624 r = self._m(tmp_path, None, None, _NEW)
625 assert r.is_clean and _files(r).get(PATH) == _NEW
626
627 def test_ours_deleted_theirs_modified_keeps_theirs(self, tmp_path: pathlib.Path) -> None:
628 """Union: ours deleted, theirs modified → keep the addition (theirs)."""
629 r = self._m(tmp_path, _OLD, None, _NEW)
630 assert r.is_clean
631 assert _files(r).get(PATH) == _NEW
632 assert r.applied_strategies.get(PATH) == "union"
633
634
635 class TestManualPermutations:
636 """Strategy: manual — force conflict on any single-branch change;
637 never conflict when both sides agree (l == r)."""
638
639 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
640 _write_attrs(tmp_path, _rule("manual"))
641 b = _snap((PATH, base_h)) if base_h else _snap()
642 o = _snap((PATH, ours_h)) if ours_h else _snap()
643 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
644 return _merge(b, o, t, tmp_path)
645
646 def test_unchanged_no_conflict(self, tmp_path: pathlib.Path) -> None:
647 """b==l==r: nothing changed — manual must NOT fire."""
648 r = self._m(tmp_path, _OLD, _OLD, _OLD)
649 assert r.is_clean
650 assert PATH not in r.conflicts
651
652 def test_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None:
653 """b!=l==r: both agree on new value — manual must NOT fire."""
654 r = self._m(tmp_path, _OLD, _NEW, _NEW)
655 assert r.is_clean
656 assert PATH not in r.conflicts
657
658 def test_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None:
659 """l==r==None: both deleted — that's agreement, manual must NOT fire."""
660 r = self._m(tmp_path, _OLD, None, None)
661 assert r.is_clean
662 assert PATH not in r.conflicts
663
664 def test_both_added_same_no_conflict(self, tmp_path: pathlib.Path) -> None:
665 """b=None, l==r: both added same content — no conflict."""
666 r = self._m(tmp_path, None, _NEW, _NEW)
667 assert r.is_clean
668 assert PATH not in r.conflicts
669
670 def test_ours_only_forces_conflict(self, tmp_path: pathlib.Path) -> None:
671 """Only ours changed → manual forces human review."""
672 r = self._m(tmp_path, _OLD, _NEW, _OLD)
673 assert PATH in r.conflicts
674 assert r.applied_strategies.get(PATH) == "manual"
675
676 def test_theirs_only_forces_conflict(self, tmp_path: pathlib.Path) -> None:
677 """Only theirs changed → manual forces human review."""
678 r = self._m(tmp_path, _OLD, _OLD, _NEW)
679 assert PATH in r.conflicts
680 assert r.applied_strategies.get(PATH) == "manual"
681
682 def test_only_ours_added_forces_conflict(self, tmp_path: pathlib.Path) -> None:
683 """b=None, only ours added → manual forces review."""
684 r = self._m(tmp_path, None, _NEW, None)
685 assert PATH in r.conflicts
686 assert r.applied_strategies.get(PATH) == "manual"
687
688 def test_only_theirs_added_forces_conflict(self, tmp_path: pathlib.Path) -> None:
689 """b=None, only theirs added → manual forces review."""
690 r = self._m(tmp_path, None, None, _NEW)
691 assert PATH in r.conflicts
692 assert r.applied_strategies.get(PATH) == "manual"
693
694 def test_divergent_forces_conflict(self, tmp_path: pathlib.Path) -> None:
695 r = self._m(tmp_path, _OLD, _NEW, _ALT)
696 assert PATH in r.conflicts
697 assert r.applied_strategies.get(PATH) == "manual"
698
699 def test_ours_deleted_theirs_modified_forces_conflict(self, tmp_path: pathlib.Path) -> None:
700 r = self._m(tmp_path, _OLD, None, _NEW)
701 assert PATH in r.conflicts
702
703 def test_ours_modified_theirs_deleted_forces_conflict(self, tmp_path: pathlib.Path) -> None:
704 r = self._m(tmp_path, _OLD, _NEW, None)
705 assert PATH in r.conflicts
706
707 def test_both_added_different_forces_conflict(self, tmp_path: pathlib.Path) -> None:
708 r = self._m(tmp_path, None, _NEW, _ALT)
709 assert PATH in r.conflicts
710 assert r.applied_strategies.get(PATH) == "manual"
711
712
713 class TestAutoPermutations:
714 """Strategy: auto (default) — standard three-way; conflict only when divergent."""
715
716 def _m(self, tmp_path: pathlib.Path, base_h, ours_h, theirs_h) -> MergeResult:
717 _write_attrs(tmp_path, _rule("auto"))
718 b = _snap((PATH, base_h)) if base_h else _snap()
719 o = _snap((PATH, ours_h)) if ours_h else _snap()
720 t = _snap((PATH, theirs_h)) if theirs_h else _snap()
721 return _merge(b, o, t, tmp_path)
722
723 def test_unchanged(self, tmp_path: pathlib.Path) -> None:
724 r = self._m(tmp_path, _OLD, _OLD, _OLD)
725 assert r.is_clean and _files(r).get(PATH) == _OLD
726
727 def test_convergent(self, tmp_path: pathlib.Path) -> None:
728 r = self._m(tmp_path, _OLD, _NEW, _NEW)
729 assert r.is_clean and _files(r).get(PATH) == _NEW
730
731 def test_ours_only_no_conflict(self, tmp_path: pathlib.Path) -> None:
732 r = self._m(tmp_path, _OLD, _NEW, _OLD)
733 assert r.is_clean and _files(r).get(PATH) == _NEW
734
735 def test_theirs_only_no_conflict(self, tmp_path: pathlib.Path) -> None:
736 r = self._m(tmp_path, _OLD, _OLD, _NEW)
737 assert r.is_clean and _files(r).get(PATH) == _NEW
738
739 def test_divergent_conflicts(self, tmp_path: pathlib.Path) -> None:
740 r = self._m(tmp_path, _OLD, _NEW, _ALT)
741 assert PATH in r.conflicts
742 assert PATH not in r.applied_strategies # auto is the silent default
743
744 def test_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None:
745 r = self._m(tmp_path, _OLD, None, None)
746 assert r.is_clean and PATH not in _files(r)
747
748 def test_both_added_same_no_conflict(self, tmp_path: pathlib.Path) -> None:
749 r = self._m(tmp_path, None, _NEW, _NEW)
750 assert r.is_clean and _files(r).get(PATH) == _NEW
751
752 def test_both_added_different_conflicts(self, tmp_path: pathlib.Path) -> None:
753 r = self._m(tmp_path, None, _NEW, _ALT)
754 assert PATH in r.conflicts
755
756 def test_only_ours_added(self, tmp_path: pathlib.Path) -> None:
757 r = self._m(tmp_path, None, _NEW, None)
758 assert r.is_clean and _files(r).get(PATH) == _NEW
759
760 def test_only_theirs_added(self, tmp_path: pathlib.Path) -> None:
761 r = self._m(tmp_path, None, None, _NEW)
762 assert r.is_clean and _files(r).get(PATH) == _NEW
763
764 def test_ours_deleted_theirs_modified_conflicts(self, tmp_path: pathlib.Path) -> None:
765 r = self._m(tmp_path, _OLD, None, _NEW)
766 assert PATH in r.conflicts
767
768 def test_ours_modified_theirs_deleted_conflicts(self, tmp_path: pathlib.Path) -> None:
769 r = self._m(tmp_path, _OLD, _NEW, None)
770 assert PATH in r.conflicts
771
772
773 # ===========================================================================
774 # Validation of the actual .museattributes rules in this repository
775 #
776 # Each test validates one rule from .museattributes:
777 # 1. muse/core/** manual priority=100
778 # 2. docs/** union priority=50
779 # 3. tests/** union priority=40 (dimension=symbols at OT level)
780 # 4. muse/cli/commands/** union priority=30 (dimension=imports at OT level)
781 # 5. pyproject.toml manual priority=20 (was "ours" — changed to manual)
782 # 6. *.md union priority=10
783 # ===========================================================================
784
785 _MUSE_ATTRS = """\
786 [meta]
787 domain = "code"
788
789 [[rules]]
790 path = "muse/core/**"
791 dimension = "*"
792 strategy = "manual"
793 comment = "Core store and object model — always needs a human eye on merge."
794 priority = 100
795
796 [[rules]]
797 path = "docs/**"
798 dimension = "*"
799 strategy = "union"
800 comment = "Documentation additions from both branches are always welcome."
801 priority = 50
802
803 [[rules]]
804 path = "tests/**"
805 dimension = "symbols"
806 strategy = "union"
807 comment = "Test additions from both branches are safe to accumulate."
808 priority = 40
809
810 [[rules]]
811 path = "muse/cli/commands/**"
812 dimension = "imports"
813 strategy = "union"
814 comment = "Import sets in command modules are independent — accumulate both sides."
815 priority = 30
816
817 [[rules]]
818 path = "pyproject.toml"
819 dimension = "*"
820 strategy = "manual"
821 comment = "Project config needs human review — never silently discard dep changes."
822 priority = 20
823
824 [[rules]]
825 path = "*.md"
826 dimension = "*"
827 strategy = "union"
828 comment = "Markdown docs — union keeps prose additions from both branches."
829 priority = 10
830 """
831
832
833 class TestMuseAttributesRules:
834 """Validate every rule in the repo's .museattributes is correct and sensible."""
835
836 def _write(self, root: pathlib.Path) -> None:
837 _write_attrs(root, _MUSE_ATTRS)
838
839 # ------------------------------------------------------------------
840 # Rule 1: muse/core/** = manual, priority=100
841 # ------------------------------------------------------------------
842
843 def test_core_unchanged_no_false_conflict(self, tmp_path: pathlib.Path) -> None:
844 """Unchanged core files MUST NOT produce conflicts (our regression fix)."""
845 self._write(tmp_path)
846 snap = _snap(
847 ("muse/core/store.py", _OLD),
848 ("muse/core/__init__.py", _OLD),
849 ("muse/core/merge_engine.py", _OLD),
850 )
851 r = _merge(snap, snap, snap, tmp_path)
852 assert r.is_clean, f"False conflicts: {r.conflicts}"
853
854 def test_core_single_branch_change_forces_conflict(self, tmp_path: pathlib.Path) -> None:
855 """Any change to muse/core/** by one branch must be flagged for review."""
856 self._write(tmp_path)
857 base = _snap(("muse/core/store.py", _OLD))
858 ours = _snap(("muse/core/store.py", _NEW))
859 theirs = _snap(("muse/core/store.py", _OLD))
860 r = _merge(base, ours, theirs, tmp_path)
861 assert "muse/core/store.py" in r.conflicts
862
863 def test_core_divergent_conflict(self, tmp_path: pathlib.Path) -> None:
864 self._write(tmp_path)
865 base = _snap(("muse/core/store.py", _OLD))
866 ours = _snap(("muse/core/store.py", _NEW))
867 theirs = _snap(("muse/core/store.py", _ALT))
868 r = _merge(base, ours, theirs, tmp_path)
869 assert "muse/core/store.py" in r.conflicts
870
871 def test_core_convergent_no_conflict(self, tmp_path: pathlib.Path) -> None:
872 """Both branches independently made the same fix → no conflict."""
873 self._write(tmp_path)
874 base = _snap(("muse/core/store.py", _OLD))
875 same = _snap(("muse/core/store.py", _NEW))
876 r = _merge(base, same, same, tmp_path)
877 assert r.is_clean
878
879 def test_core_rule_beats_lower_priority_wildcard(self, tmp_path: pathlib.Path) -> None:
880 """muse/core/**=manual (p=100) wins over any hypothetical catch-all."""
881 self._write(tmp_path)
882 # Any file under muse/core/ gets manual, not auto
883 base = _snap(("muse/core/new_module.py", _OLD))
884 ours = _snap(("muse/core/new_module.py", _NEW))
885 theirs = _snap(("muse/core/new_module.py", _OLD))
886 r = _merge(base, ours, theirs, tmp_path)
887 assert r.applied_strategies.get("muse/core/new_module.py") == "manual"
888
889 # ------------------------------------------------------------------
890 # Rule 2: docs/** = union, priority=50
891 # ------------------------------------------------------------------
892
893 def test_docs_disjoint_additions_both_kept(self, tmp_path: pathlib.Path) -> None:
894 self._write(tmp_path)
895 base = _snap()
896 ours = _snap(("docs/api.md", _NEW))
897 theirs = _snap(("docs/guide.md", _ALT))
898 r = _merge(base, ours, theirs, tmp_path)
899 assert r.is_clean
900 assert "docs/api.md" in _files(r)
901 assert "docs/guide.md" in _files(r)
902
903 def test_docs_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None:
904 """Union on blob conflict: prefers left (ours), no conflict raised."""
905 self._write(tmp_path)
906 base = _snap(("docs/readme.md", _OLD))
907 ours = _snap(("docs/readme.md", _NEW))
908 theirs = _snap(("docs/readme.md", _ALT))
909 r = _merge(base, ours, theirs, tmp_path)
910 assert r.is_clean
911 assert _files(r)["docs/readme.md"] == _NEW
912 assert r.applied_strategies.get("docs/readme.md") == "union"
913
914 # ------------------------------------------------------------------
915 # Rule 3: tests/** = union (at file level, dimension ignored when * passed)
916 # ------------------------------------------------------------------
917
918 def test_tests_disjoint_files_both_kept(self, tmp_path: pathlib.Path) -> None:
919 self._write(tmp_path)
920 base = _snap()
921 ours = _snap(("tests/test_foo.py", _NEW))
922 theirs = _snap(("tests/test_bar.py", _ALT))
923 r = _merge(base, ours, theirs, tmp_path)
924 assert r.is_clean
925 assert "tests/test_foo.py" in _files(r)
926 assert "tests/test_bar.py" in _files(r)
927
928 def test_tests_same_file_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None:
929 """When both branches modify the same test file divergently, union picks ours."""
930 self._write(tmp_path)
931 base = _snap(("tests/test_merge.py", _OLD))
932 ours = _snap(("tests/test_merge.py", _NEW))
933 theirs = _snap(("tests/test_merge.py", _ALT))
934 r = _merge(base, ours, theirs, tmp_path)
935 assert r.is_clean
936 assert _files(r)["tests/test_merge.py"] == _NEW
937 assert r.applied_strategies.get("tests/test_merge.py") == "union"
938
939 # ------------------------------------------------------------------
940 # Rule 4: muse/cli/commands/** = union (at file level)
941 # ------------------------------------------------------------------
942
943 def test_commands_divergent_prefers_ours(self, tmp_path: pathlib.Path) -> None:
944 self._write(tmp_path)
945 base = _snap(("muse/cli/commands/merge.py", _OLD))
946 ours = _snap(("muse/cli/commands/merge.py", _NEW))
947 theirs = _snap(("muse/cli/commands/merge.py", _ALT))
948 r = _merge(base, ours, theirs, tmp_path)
949 assert r.is_clean
950 assert _files(r)["muse/cli/commands/merge.py"] == _NEW
951 assert r.applied_strategies.get("muse/cli/commands/merge.py") == "union"
952
953 # ------------------------------------------------------------------
954 # Rule 5: pyproject.toml = manual, priority=20
955 # (was "ours" — that silently discarded incoming dependency changes)
956 # ------------------------------------------------------------------
957
958 def test_pyproject_unchanged_no_conflict(self, tmp_path: pathlib.Path) -> None:
959 self._write(tmp_path)
960 snap = _snap(("pyproject.toml", _OLD))
961 r = _merge(snap, snap, snap, tmp_path)
962 assert r.is_clean
963
964 def test_pyproject_single_branch_change_forces_conflict(self, tmp_path: pathlib.Path) -> None:
965 """A feature branch adding a dependency must NOT be silently discarded."""
966 self._write(tmp_path)
967 base = _snap(("pyproject.toml", _OLD))
968 ours = _snap(("pyproject.toml", _OLD)) # dev unchanged
969 theirs = _snap(("pyproject.toml", _NEW)) # feature added a dep
970 r = _merge(base, ours, theirs, tmp_path)
971 # With manual: forced conflict, human reviews whether to accept the dep
972 assert "pyproject.toml" in r.conflicts
973 assert r.applied_strategies.get("pyproject.toml") == "manual"
974
975 def test_pyproject_divergent_forces_conflict(self, tmp_path: pathlib.Path) -> None:
976 self._write(tmp_path)
977 base = _snap(("pyproject.toml", _OLD))
978 ours = _snap(("pyproject.toml", _NEW))
979 theirs = _snap(("pyproject.toml", _ALT))
980 r = _merge(base, ours, theirs, tmp_path)
981 assert "pyproject.toml" in r.conflicts
982
983 # ------------------------------------------------------------------
984 # Rule 6: *.md = union, priority=10
985 # ------------------------------------------------------------------
986
987 def test_root_md_union(self, tmp_path: pathlib.Path) -> None:
988 """Root-level *.md files get union treatment."""
989 self._write(tmp_path)
990 base = _snap(("CHANGELOG.md", _OLD))
991 ours = _snap(("CHANGELOG.md", _NEW))
992 theirs = _snap(("CHANGELOG.md", _ALT))
993 r = _merge(base, ours, theirs, tmp_path)
994 assert r.is_clean
995 assert r.applied_strategies.get("CHANGELOG.md") == "union"
996
997 def test_nested_md_NOT_matched_by_root_glob(self, tmp_path: pathlib.Path) -> None:
998 """docs/api.md matches docs/** (p=50) not *.md (p=10) — docs rule wins."""
999 self._write(tmp_path)
1000 base = _snap(("docs/api.md", _OLD))
1001 ours = _snap(("docs/api.md", _NEW))
1002 theirs = _snap(("docs/api.md", _ALT))
1003 r = _merge(base, ours, theirs, tmp_path)
1004 # Both docs/** and *.md are union — result is union either way.
1005 # Key: docs/** (p=50) fires first, not *.md (p=10).
1006 assert r.applied_strategies.get("docs/api.md") == "union"
1007
1008 # ------------------------------------------------------------------
1009 # Priority interactions
1010 # ------------------------------------------------------------------
1011
1012 def test_core_manual_beats_any_other_rule(self, tmp_path: pathlib.Path) -> None:
1013 """muse/core/** (p=100) must win over every other active rule."""
1014 self._write(tmp_path)
1015 # This file could hypothetically match docs/** if it were there,
1016 # but muse/core/ files must always get manual.
1017 base = _snap(("muse/core/store.py", _OLD))
1018 ours = _snap(("muse/core/store.py", _NEW))
1019 theirs = _snap(("muse/core/store.py", _OLD))
1020 r = _merge(base, ours, theirs, tmp_path)
1021 assert r.applied_strategies.get("muse/core/store.py") == "manual"
1022
1023 def test_unmatched_path_gets_auto(self, tmp_path: pathlib.Path) -> None:
1024 """Files not matching any rule fall back to auto (standard conflict)."""
1025 self._write(tmp_path)
1026 base = _snap(("some_random_file.py", _OLD))
1027 ours = _snap(("some_random_file.py", _NEW))
1028 theirs = _snap(("some_random_file.py", _ALT))
1029 r = _merge(base, ours, theirs, tmp_path)
1030 assert "some_random_file.py" in r.conflicts
1031 assert "some_random_file.py" not in r.applied_strategies
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago