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