gabriel / muse public
test_rerere.py python
881 lines 29.3 KB
95367f8d feat: implement muse rerere — reuse recorded conflict resolutions Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """Tests for muse rerere — Reuse Recorded Resolution.
2
3 Covers:
4 - Core engine: fingerprinting, preimage recording, resolution save/load,
5 apply_cached, auto_apply, record_resolutions, list_records, forget, clear, gc
6 - CLI: muse rerere, record, status, forget, clear, gc subcommands
7 - domain.py: RererePlugin optional protocol
8 - Integration: merge auto-apply + commit recording
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import hashlib
15 import json
16 import pathlib
17
18 import pytest
19 from typer.testing import CliRunner
20
21 from muse.cli.app import cli
22 from muse.core import rerere as rerere_mod
23 from muse.core.rerere import (
24 RerereRecord,
25 auto_apply,
26 clear_all,
27 conflict_fingerprint,
28 compute_fingerprint,
29 forget_record,
30 gc_stale,
31 has_resolution,
32 list_records,
33 load_record,
34 record_preimage,
35 record_resolutions,
36 rr_cache_dir,
37 save_resolution,
38 )
39 from muse.core.object_store import write_object
40 from muse.core.schema import DomainSchema
41 from muse.domain import (
42 DriftReport,
43 LiveState,
44 MergeResult,
45 MuseDomainPlugin,
46 RererePlugin,
47 SnapshotManifest,
48 StateSnapshot,
49 StateDelta,
50 StructuredDelta,
51 )
52
53 runner = CliRunner()
54
55
56 # ---------------------------------------------------------------------------
57 # Stub plugin implementations (fully typed, no Any, no Protocol inheritance)
58 # ---------------------------------------------------------------------------
59
60
61 class _StubPlugin:
62 """Minimal domain plugin stub — satisfies MuseDomainPlugin structurally."""
63
64 def snapshot(self, live_state: LiveState) -> StateSnapshot:
65 raise NotImplementedError
66
67 def diff(
68 self,
69 base: StateSnapshot,
70 target: StateSnapshot,
71 *,
72 repo_root: pathlib.Path | None = None,
73 ) -> StateDelta:
74 raise NotImplementedError
75
76 def merge(
77 self,
78 base: StateSnapshot,
79 left: StateSnapshot,
80 right: StateSnapshot,
81 *,
82 repo_root: pathlib.Path | None = None,
83 ) -> MergeResult:
84 raise NotImplementedError
85
86 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
87 raise NotImplementedError
88
89 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
90 raise NotImplementedError
91
92 def schema(self) -> DomainSchema:
93 raise NotImplementedError
94
95
96 class _CustomFPPlugin(_StubPlugin):
97 """Plugin that overrides conflict_fingerprint with a fixed custom value."""
98
99 def __init__(self, fp: str) -> None:
100 self._fp = fp
101
102 def conflict_fingerprint(
103 self,
104 path: str,
105 ours_id: str,
106 theirs_id: str,
107 repo_root: pathlib.Path,
108 ) -> str:
109 return self._fp
110
111
112 class _ErrorFPPlugin(_StubPlugin):
113 """Plugin whose conflict_fingerprint always raises."""
114
115 def conflict_fingerprint(
116 self,
117 path: str,
118 ours_id: str,
119 theirs_id: str,
120 repo_root: pathlib.Path,
121 ) -> str:
122 raise RuntimeError("plugin error")
123
124
125 class _ShortFPPlugin(_StubPlugin):
126 """Plugin whose conflict_fingerprint returns an invalid (short) fingerprint."""
127
128 def conflict_fingerprint(
129 self,
130 path: str,
131 ours_id: str,
132 theirs_id: str,
133 repo_root: pathlib.Path,
134 ) -> str:
135 return "too_short"
136
137
138 class _RerereEnabledPlugin(_StubPlugin):
139 """Full RererePlugin-compatible stub with a deterministic fingerprint."""
140
141 def conflict_fingerprint(
142 self,
143 path: str,
144 ours_id: str,
145 theirs_id: str,
146 repo_root: pathlib.Path,
147 ) -> str:
148 return "e" * 64
149
150
151 def _make_plugin() -> MuseDomainPlugin:
152 return _StubPlugin()
153
154
155 # ---------------------------------------------------------------------------
156 # Helpers
157 # ---------------------------------------------------------------------------
158
159
160 def _sha(content: bytes) -> str:
161 return hashlib.sha256(content).hexdigest()
162
163
164 @pytest.fixture()
165 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
166 """Initialise a minimal Muse repository in tmp_path and set it as cwd."""
167 monkeypatch.chdir(tmp_path)
168 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
169 result = runner.invoke(cli, ["init", "--domain", "midi"])
170 assert result.exit_code == 0, result.output
171 return tmp_path
172
173
174 @pytest.fixture()
175 def ours_id(repo: pathlib.Path) -> str:
176 content = b"ours content for conflict"
177 oid = _sha(content)
178 write_object(repo, oid, content)
179 return oid
180
181
182 @pytest.fixture()
183 def theirs_id(repo: pathlib.Path) -> str:
184 content = b"theirs content for conflict"
185 oid = _sha(content)
186 write_object(repo, oid, content)
187 return oid
188
189
190 @pytest.fixture()
191 def resolution_id(repo: pathlib.Path) -> str:
192 content = b"resolved content after conflict"
193 oid = _sha(content)
194 write_object(repo, oid, content)
195 return oid
196
197
198 # ---------------------------------------------------------------------------
199 # Unit tests: fingerprinting
200 # ---------------------------------------------------------------------------
201
202
203 class TestConflictFingerprint:
204 def test_deterministic(self) -> None:
205 a = "a" * 64
206 b = "b" * 64
207 assert conflict_fingerprint(a, b) == conflict_fingerprint(a, b)
208
209 def test_commutative(self) -> None:
210 """Order of ours/theirs must not affect the fingerprint."""
211 a = "a" * 64
212 b = "b" * 64
213 assert conflict_fingerprint(a, b) == conflict_fingerprint(b, a)
214
215 def test_different_inputs_produce_different_fingerprints(self) -> None:
216 a = "a" * 64
217 b = "b" * 64
218 c = "c" * 64
219 assert conflict_fingerprint(a, b) != conflict_fingerprint(a, c)
220
221 def test_identical_sides_produces_valid_fingerprint(self) -> None:
222 a = "a" * 64
223 fp = conflict_fingerprint(a, a)
224 assert len(fp) == 64
225 int(fp, 16) # raises ValueError if not valid hex
226
227 def test_output_is_64_char_hex(self) -> None:
228 fp = conflict_fingerprint("a" * 64, "b" * 64)
229 assert len(fp) == 64
230 int(fp, 16)
231
232
233 class TestComputeFingerprint:
234 def test_falls_back_to_default_for_plain_plugin(
235 self, repo: pathlib.Path, ours_id: str, theirs_id: str
236 ) -> None:
237 plugin = _make_plugin()
238 fp = compute_fingerprint("track.mid", ours_id, theirs_id, plugin, repo)
239 assert fp == conflict_fingerprint(ours_id, theirs_id)
240
241 def test_uses_plugin_fingerprint_when_conflict_fingerprint_present(
242 self, repo: pathlib.Path, ours_id: str, theirs_id: str
243 ) -> None:
244 custom_fp = "d" * 64
245 plugin = _CustomFPPlugin(custom_fp)
246 fp = compute_fingerprint("track.mid", ours_id, theirs_id, plugin, repo)
247 assert fp == custom_fp
248
249 def test_falls_back_on_plugin_exception(
250 self, repo: pathlib.Path, ours_id: str, theirs_id: str
251 ) -> None:
252 plugin = _ErrorFPPlugin()
253 fp = compute_fingerprint("track.mid", ours_id, theirs_id, plugin, repo)
254 assert fp == conflict_fingerprint(ours_id, theirs_id)
255
256 def test_falls_back_on_invalid_short_fingerprint(
257 self, repo: pathlib.Path, ours_id: str, theirs_id: str
258 ) -> None:
259 plugin = _ShortFPPlugin()
260 fp = compute_fingerprint("track.mid", ours_id, theirs_id, plugin, repo)
261 assert fp == conflict_fingerprint(ours_id, theirs_id)
262
263
264 # ---------------------------------------------------------------------------
265 # Unit tests: record_preimage / save_resolution / load_record
266 # ---------------------------------------------------------------------------
267
268
269 class TestPreimageLifecycle:
270 def test_record_preimage_creates_meta_file(
271 self, repo: pathlib.Path, ours_id: str, theirs_id: str
272 ) -> None:
273 plugin = _make_plugin()
274 fp = record_preimage(repo, "beat.mid", ours_id, theirs_id, "midi", plugin)
275 meta_p = repo / ".muse" / "rr-cache" / fp / "meta.json"
276 assert meta_p.exists()
277 data = json.loads(meta_p.read_text(encoding="utf-8"))
278 assert data["path"] == "beat.mid"
279 assert data["ours_id"] == ours_id
280 assert data["theirs_id"] == theirs_id
281 assert data["domain"] == "midi"
282
283 def test_record_preimage_is_idempotent(
284 self, repo: pathlib.Path, ours_id: str, theirs_id: str
285 ) -> None:
286 plugin = _make_plugin()
287 fp1 = record_preimage(repo, "beat.mid", ours_id, theirs_id, "midi", plugin)
288 fp2 = record_preimage(repo, "beat.mid", ours_id, theirs_id, "midi", plugin)
289 assert fp1 == fp2
290
291 def test_load_record_returns_none_for_missing_fingerprint(
292 self, repo: pathlib.Path
293 ) -> None:
294 assert load_record(repo, "a" * 64) is None
295
296 def test_load_record_no_resolution(
297 self, repo: pathlib.Path, ours_id: str, theirs_id: str
298 ) -> None:
299 plugin = _make_plugin()
300 fp = record_preimage(repo, "bass.mid", ours_id, theirs_id, "midi", plugin)
301 rec = load_record(repo, fp)
302 assert rec is not None
303 assert rec.path == "bass.mid"
304 assert rec.ours_id == ours_id
305 assert rec.theirs_id == theirs_id
306 assert rec.has_resolution is False
307 assert rec.resolution_id is None
308
309 def test_save_resolution_persists_and_loads(
310 self,
311 repo: pathlib.Path,
312 ours_id: str,
313 theirs_id: str,
314 resolution_id: str,
315 ) -> None:
316 plugin = _make_plugin()
317 fp = record_preimage(repo, "lead.mid", ours_id, theirs_id, "midi", plugin)
318 save_resolution(repo, fp, resolution_id)
319 rec = load_record(repo, fp)
320 assert rec is not None
321 assert rec.has_resolution is True
322 assert rec.resolution_id == resolution_id
323
324 def test_save_resolution_raises_without_preimage(
325 self, repo: pathlib.Path, resolution_id: str
326 ) -> None:
327 with pytest.raises(FileNotFoundError):
328 save_resolution(repo, "b" * 64, resolution_id)
329
330 def test_save_resolution_rejects_invalid_id(
331 self, repo: pathlib.Path, ours_id: str, theirs_id: str
332 ) -> None:
333 plugin = _make_plugin()
334 fp = record_preimage(repo, "x.mid", ours_id, theirs_id, "midi", plugin)
335 with pytest.raises(ValueError):
336 save_resolution(repo, fp, "not-a-valid-id")
337
338 def test_has_resolution_false_before_save(
339 self, repo: pathlib.Path, ours_id: str, theirs_id: str
340 ) -> None:
341 plugin = _make_plugin()
342 fp = record_preimage(repo, "y.mid", ours_id, theirs_id, "midi", plugin)
343 assert has_resolution(repo, fp) is False
344
345 def test_has_resolution_true_after_save(
346 self,
347 repo: pathlib.Path,
348 ours_id: str,
349 theirs_id: str,
350 resolution_id: str,
351 ) -> None:
352 plugin = _make_plugin()
353 fp = record_preimage(repo, "z.mid", ours_id, theirs_id, "midi", plugin)
354 save_resolution(repo, fp, resolution_id)
355 assert has_resolution(repo, fp) is True
356
357
358 # ---------------------------------------------------------------------------
359 # Unit tests: apply_cached
360 # ---------------------------------------------------------------------------
361
362
363 class TestApplyCached:
364 def test_apply_restores_file_to_working_tree(
365 self,
366 repo: pathlib.Path,
367 ours_id: str,
368 theirs_id: str,
369 resolution_id: str,
370 ) -> None:
371 from muse.core.rerere import apply_cached
372
373 plugin = _make_plugin()
374 fp = record_preimage(repo, "track.mid", ours_id, theirs_id, "midi", plugin)
375 save_resolution(repo, fp, resolution_id)
376
377 dest = repo / "track.mid"
378 result = apply_cached(repo, fp, dest)
379
380 assert result is True
381 assert dest.exists()
382 assert dest.read_bytes() == b"resolved content after conflict"
383
384 def test_apply_returns_false_when_no_resolution(
385 self, repo: pathlib.Path, ours_id: str, theirs_id: str
386 ) -> None:
387 from muse.core.rerere import apply_cached
388
389 plugin = _make_plugin()
390 fp = record_preimage(repo, "no_res.mid", ours_id, theirs_id, "midi", plugin)
391 dest = repo / "no_res.mid"
392 result = apply_cached(repo, fp, dest)
393 assert result is False
394 assert not dest.exists()
395
396 def test_apply_returns_false_when_blob_missing_from_store(
397 self, repo: pathlib.Path, ours_id: str, theirs_id: str
398 ) -> None:
399 from muse.core.rerere import _resolution_path, _write_atomic, apply_cached
400
401 plugin = _make_plugin()
402 fp = record_preimage(repo, "missing_blob.mid", ours_id, theirs_id, "midi", plugin)
403 # Write a resolution ID that is not in the store.
404 ghost_id = "f" * 64
405 res_p = _resolution_path(repo, fp)
406 _write_atomic(res_p, ghost_id)
407
408 dest = repo / "missing_blob.mid"
409 result = apply_cached(repo, fp, dest)
410 assert result is False
411
412
413 # ---------------------------------------------------------------------------
414 # Unit tests: list_records / forget_record / clear_all / gc_stale
415 # ---------------------------------------------------------------------------
416
417
418 class TestBulkOperations:
419 def test_list_records_empty_when_no_cache(self, repo: pathlib.Path) -> None:
420 assert list_records(repo) == []
421
422 def test_list_records_returns_all_entries(
423 self, repo: pathlib.Path, ours_id: str, theirs_id: str
424 ) -> None:
425 plugin = _make_plugin()
426 record_preimage(repo, "a.mid", ours_id, theirs_id, "midi", plugin)
427
428 other_ours = _sha(b"other ours")
429 other_theirs = _sha(b"other theirs")
430 write_object(repo, other_ours, b"other ours")
431 write_object(repo, other_theirs, b"other theirs")
432 record_preimage(repo, "b.mid", other_ours, other_theirs, "midi", plugin)
433
434 records = list_records(repo)
435 assert len(records) == 2
436 paths = {r.path for r in records}
437 assert paths == {"a.mid", "b.mid"}
438
439 def test_list_records_sorted_most_recent_first(
440 self, repo: pathlib.Path
441 ) -> None:
442 plugin = _make_plugin()
443 ids: list[str] = []
444 for i in range(3):
445 content = f"content {i}".encode()
446 oid = _sha(content)
447 write_object(repo, oid, content)
448 ids.append(oid)
449
450 record_preimage(repo, "first.mid", ids[0], ids[1], "midi", plugin)
451 record_preimage(repo, "second.mid", ids[1], ids[2], "midi", plugin)
452
453 records = list_records(repo)
454 assert len(records) == 2
455 assert records[0].recorded_at >= records[1].recorded_at
456
457 def test_forget_record_removes_entry(
458 self, repo: pathlib.Path, ours_id: str, theirs_id: str
459 ) -> None:
460 plugin = _make_plugin()
461 fp = record_preimage(repo, "forget.mid", ours_id, theirs_id, "midi", plugin)
462 assert forget_record(repo, fp) is True
463 assert load_record(repo, fp) is None
464
465 def test_forget_record_returns_false_for_missing(self, repo: pathlib.Path) -> None:
466 assert forget_record(repo, "c" * 64) is False
467
468 def test_clear_all_removes_all_entries(
469 self, repo: pathlib.Path, ours_id: str, theirs_id: str
470 ) -> None:
471 plugin = _make_plugin()
472 # Use genuinely distinct pairs so the commutative fingerprint produces
473 # two separate cache entries.
474 alt_ours = _sha(b"alt ours clear")
475 alt_theirs = _sha(b"alt theirs clear")
476 write_object(repo, alt_ours, b"alt ours clear")
477 write_object(repo, alt_theirs, b"alt theirs clear")
478
479 record_preimage(repo, "x.mid", ours_id, theirs_id, "midi", plugin)
480 record_preimage(repo, "y.mid", alt_ours, alt_theirs, "midi", plugin)
481 removed = clear_all(repo)
482 assert removed == 2
483 assert list_records(repo) == []
484
485 def test_clear_all_on_empty_cache_returns_zero(self, repo: pathlib.Path) -> None:
486 assert clear_all(repo) == 0
487
488 def test_gc_removes_stale_preimage_only_entries(
489 self, repo: pathlib.Path, ours_id: str, theirs_id: str, resolution_id: str
490 ) -> None:
491 plugin = _make_plugin()
492
493 # Entry with resolution — keep regardless of age.
494 fp_with_res = record_preimage(repo, "keep.mid", ours_id, theirs_id, "midi", plugin)
495 save_resolution(repo, fp_with_res, resolution_id)
496
497 # Young preimage-only — keep.
498 young_ours = _sha(b"young ours")
499 young_theirs = _sha(b"young theirs")
500 write_object(repo, young_ours, b"young ours")
501 write_object(repo, young_theirs, b"young theirs")
502 record_preimage(repo, "young.mid", young_ours, young_theirs, "midi", plugin)
503
504 # Stale preimage-only — should be removed.
505 stale_ours = _sha(b"stale ours")
506 stale_theirs = _sha(b"stale theirs")
507 write_object(repo, stale_ours, b"stale ours")
508 write_object(repo, stale_theirs, b"stale theirs")
509 fp_stale = record_preimage(repo, "stale.mid", stale_ours, stale_theirs, "midi", plugin)
510
511 meta_p = repo / ".muse" / "rr-cache" / fp_stale / "meta.json"
512 data = json.loads(meta_p.read_text(encoding="utf-8"))
513 old_ts = (
514 datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=61)
515 ).isoformat()
516 data["recorded_at"] = old_ts
517 meta_p.write_text(json.dumps(data), encoding="utf-8")
518
519 removed = gc_stale(repo)
520 assert removed == 1
521 assert load_record(repo, fp_stale) is None
522 assert load_record(repo, fp_with_res) is not None
523
524
525 # ---------------------------------------------------------------------------
526 # Unit tests: auto_apply
527 # ---------------------------------------------------------------------------
528
529
530 class TestAutoApply:
531 def test_auto_apply_resolves_cached_conflicts(
532 self,
533 repo: pathlib.Path,
534 ours_id: str,
535 theirs_id: str,
536 resolution_id: str,
537 ) -> None:
538 plugin = _make_plugin()
539 fp = record_preimage(repo, "auto.mid", ours_id, theirs_id, "midi", plugin)
540 save_resolution(repo, fp, resolution_id)
541
542 ours_manifest = {"auto.mid": ours_id}
543 theirs_manifest = {"auto.mid": theirs_id}
544
545 resolved, remaining = auto_apply(
546 repo, ["auto.mid"], ours_manifest, theirs_manifest, "midi", plugin
547 )
548
549 assert "auto.mid" in resolved
550 assert resolved["auto.mid"] == resolution_id
551 assert remaining == []
552 assert (repo / "auto.mid").exists()
553
554 def test_auto_apply_records_preimage_for_unresolved(
555 self,
556 repo: pathlib.Path,
557 ours_id: str,
558 theirs_id: str,
559 ) -> None:
560 plugin = _make_plugin()
561 ours_manifest = {"new.mid": ours_id}
562 theirs_manifest = {"new.mid": theirs_id}
563
564 resolved, remaining = auto_apply(
565 repo, ["new.mid"], ours_manifest, theirs_manifest, "midi", plugin
566 )
567
568 assert resolved == {}
569 assert remaining == ["new.mid"]
570 fp = conflict_fingerprint(ours_id, theirs_id)
571 assert load_record(repo, fp) is not None
572
573 def test_auto_apply_skips_deletion_conflicts(
574 self, repo: pathlib.Path, ours_id: str
575 ) -> None:
576 plugin = _make_plugin()
577 ours_manifest = {"deleted.mid": ours_id}
578 theirs_manifest: dict[str, str] = {}
579
580 resolved, remaining = auto_apply(
581 repo, ["deleted.mid"], ours_manifest, theirs_manifest, "midi", plugin
582 )
583 assert resolved == {}
584 assert "deleted.mid" in remaining
585
586 def test_auto_apply_mixed_cached_and_uncached(
587 self,
588 repo: pathlib.Path,
589 ours_id: str,
590 theirs_id: str,
591 resolution_id: str,
592 ) -> None:
593 plugin = _make_plugin()
594 fp_a = record_preimage(repo, "a.mid", ours_id, theirs_id, "midi", plugin)
595 save_resolution(repo, fp_a, resolution_id)
596
597 b_ours = _sha(b"b ours")
598 b_theirs = _sha(b"b theirs")
599 write_object(repo, b_ours, b"b ours")
600 write_object(repo, b_theirs, b"b theirs")
601
602 ours_manifest = {"a.mid": ours_id, "b.mid": b_ours}
603 theirs_manifest = {"a.mid": theirs_id, "b.mid": b_theirs}
604
605 resolved, remaining = auto_apply(
606 repo,
607 ["a.mid", "b.mid"],
608 ours_manifest,
609 theirs_manifest,
610 "midi",
611 plugin,
612 )
613
614 assert "a.mid" in resolved
615 assert "b.mid" in remaining
616
617
618 # ---------------------------------------------------------------------------
619 # Unit tests: record_resolutions
620 # ---------------------------------------------------------------------------
621
622
623 class TestRecordResolutions:
624 def test_records_user_resolution_after_commit(
625 self,
626 repo: pathlib.Path,
627 ours_id: str,
628 theirs_id: str,
629 resolution_id: str,
630 ) -> None:
631 plugin = _make_plugin()
632 fp = record_preimage(repo, "resolved.mid", ours_id, theirs_id, "midi", plugin)
633
634 new_manifest = {"resolved.mid": resolution_id}
635 saved = record_resolutions(
636 repo,
637 ["resolved.mid"],
638 {"resolved.mid": ours_id},
639 {"resolved.mid": theirs_id},
640 new_manifest,
641 "midi",
642 plugin,
643 )
644
645 assert "resolved.mid" in saved
646 rec = load_record(repo, fp)
647 assert rec is not None
648 assert rec.resolution_id == resolution_id
649
650 def test_skips_paths_not_in_new_manifest(
651 self, repo: pathlib.Path, ours_id: str, theirs_id: str
652 ) -> None:
653 plugin = _make_plugin()
654 saved = record_resolutions(
655 repo,
656 ["deleted.mid"],
657 {"deleted.mid": ours_id},
658 {"deleted.mid": theirs_id},
659 {},
660 "midi",
661 plugin,
662 )
663 assert saved == []
664
665 def test_creates_preimage_if_missing(
666 self, repo: pathlib.Path, ours_id: str, theirs_id: str, resolution_id: str
667 ) -> None:
668 plugin = _make_plugin()
669 new_manifest = {"late.mid": resolution_id}
670 saved = record_resolutions(
671 repo,
672 ["late.mid"],
673 {"late.mid": ours_id},
674 {"late.mid": theirs_id},
675 new_manifest,
676 "midi",
677 plugin,
678 )
679 assert "late.mid" in saved
680 fp = conflict_fingerprint(ours_id, theirs_id)
681 rec = load_record(repo, fp)
682 assert rec is not None
683 assert rec.resolution_id == resolution_id
684
685
686 # ---------------------------------------------------------------------------
687 # CLI tests: muse rerere (default — apply)
688 # ---------------------------------------------------------------------------
689
690
691 class TestRerereCliApply:
692 def test_no_merge_in_progress(self, repo: pathlib.Path) -> None:
693 result = runner.invoke(cli, ["rerere"], env={"HOME": str(repo)})
694 assert result.exit_code == 0
695 assert "nothing" in result.output.lower() or "No merge" in result.output
696
697 def test_unknown_format_errors(self, repo: pathlib.Path) -> None:
698 result = runner.invoke(
699 cli, ["rerere", "--format", "xml"], env={"HOME": str(repo)}
700 )
701 assert result.exit_code != 0
702
703 def test_json_format_with_no_merge(self, repo: pathlib.Path) -> None:
704 result = runner.invoke(
705 cli, ["rerere", "--format", "json"], env={"HOME": str(repo)}
706 )
707 assert result.exit_code == 0
708
709
710 class TestRerereCliRecord:
711 def test_record_no_merge_in_progress(self, repo: pathlib.Path) -> None:
712 result = runner.invoke(cli, ["rerere", "record"], env={"HOME": str(repo)})
713 assert result.exit_code == 0
714 assert "nothing" in result.output.lower() or "No merge" in result.output
715
716 def test_record_unknown_format(self, repo: pathlib.Path) -> None:
717 result = runner.invoke(
718 cli, ["rerere", "record", "--format", "toml"], env={"HOME": str(repo)}
719 )
720 assert result.exit_code != 0
721
722
723 class TestRerereCliStatus:
724 def test_status_empty_cache(self, repo: pathlib.Path) -> None:
725 result = runner.invoke(cli, ["rerere", "status"], env={"HOME": str(repo)})
726 assert result.exit_code == 0
727 assert "No rerere records" in result.output
728
729 def test_status_shows_records(
730 self, repo: pathlib.Path, ours_id: str, theirs_id: str, resolution_id: str
731 ) -> None:
732 plugin = _make_plugin()
733 fp = record_preimage(repo, "status.mid", ours_id, theirs_id, "midi", plugin)
734 save_resolution(repo, fp, resolution_id)
735
736 result = runner.invoke(cli, ["rerere", "status"], env={"HOME": str(repo)})
737 assert result.exit_code == 0
738 assert fp[:12] in result.output
739 assert "status.mid" in result.output
740
741 def test_status_json_format(
742 self, repo: pathlib.Path, ours_id: str, theirs_id: str
743 ) -> None:
744 plugin = _make_plugin()
745 record_preimage(repo, "json_status.mid", ours_id, theirs_id, "midi", plugin)
746
747 result = runner.invoke(
748 cli, ["rerere", "status", "--format", "json"], env={"HOME": str(repo)}
749 )
750 assert result.exit_code == 0
751 data = json.loads(result.output)
752 assert data["total"] == 1
753 assert data["records"][0]["path"] == "json_status.mid"
754 assert data["records"][0]["has_resolution"] is False
755
756
757 class TestRerereCliClear:
758 def test_clear_empty(self, repo: pathlib.Path) -> None:
759 result = runner.invoke(
760 cli, ["rerere", "clear", "--yes"], env={"HOME": str(repo)}
761 )
762 assert result.exit_code == 0
763
764 def test_clear_removes_records(
765 self, repo: pathlib.Path, ours_id: str, theirs_id: str
766 ) -> None:
767 plugin = _make_plugin()
768 record_preimage(repo, "clear.mid", ours_id, theirs_id, "midi", plugin)
769
770 result = runner.invoke(
771 cli, ["rerere", "clear", "--yes"], env={"HOME": str(repo)}
772 )
773 assert result.exit_code == 0
774 assert list_records(repo) == []
775
776 def test_clear_json_format(
777 self, repo: pathlib.Path, ours_id: str, theirs_id: str
778 ) -> None:
779 plugin = _make_plugin()
780 record_preimage(repo, "gc_test.mid", ours_id, theirs_id, "midi", plugin)
781
782 result = runner.invoke(
783 cli,
784 ["rerere", "clear", "--yes", "--format", "json"],
785 env={"HOME": str(repo)},
786 )
787 assert result.exit_code == 0
788 data = json.loads(result.output)
789 assert data["removed"] == 1
790
791
792 class TestRerereCliGC:
793 def test_gc_nothing_to_remove(self, repo: pathlib.Path) -> None:
794 result = runner.invoke(cli, ["rerere", "gc"], env={"HOME": str(repo)})
795 assert result.exit_code == 0
796 assert "nothing" in result.output.lower()
797
798 def test_gc_json_format(self, repo: pathlib.Path) -> None:
799 result = runner.invoke(
800 cli, ["rerere", "gc", "--format", "json"], env={"HOME": str(repo)}
801 )
802 assert result.exit_code == 0
803 data = json.loads(result.output)
804 assert "removed" in data
805
806
807 # ---------------------------------------------------------------------------
808 # Domain protocol: RererePlugin
809 # ---------------------------------------------------------------------------
810
811
812 class TestRererePluginProtocol:
813 def test_isinstance_detection_with_all_methods(self) -> None:
814 """RererePlugin is @runtime_checkable — isinstance must detect it structurally."""
815 plugin = _RerereEnabledPlugin()
816 assert isinstance(plugin, RererePlugin)
817
818 def test_plain_plugin_is_not_rerere_plugin(self) -> None:
819 plugin = _StubPlugin()
820 assert not isinstance(plugin, RererePlugin)
821
822 def test_custom_fp_plugin_isinstance(self) -> None:
823 plugin = _CustomFPPlugin("f" * 64)
824 assert isinstance(plugin, RererePlugin)
825
826
827 # ---------------------------------------------------------------------------
828 # Integration: rr-cache directory layout
829 # ---------------------------------------------------------------------------
830
831
832 class TestRRCacheLayout:
833 def test_rr_cache_dir_path(self, repo: pathlib.Path) -> None:
834 cache = rr_cache_dir(repo)
835 assert cache == repo / ".muse" / "rr-cache"
836
837 def test_preimage_creates_correct_directory_tree(
838 self, repo: pathlib.Path, ours_id: str, theirs_id: str
839 ) -> None:
840 plugin = _make_plugin()
841 fp = record_preimage(repo, "layout.mid", ours_id, theirs_id, "midi", plugin)
842 entry_dir = repo / ".muse" / "rr-cache" / fp
843 assert entry_dir.is_dir()
844 assert (entry_dir / "meta.json").is_file()
845 assert not (entry_dir / "resolution").exists()
846
847 def test_resolution_file_contains_valid_hex_id(
848 self,
849 repo: pathlib.Path,
850 ours_id: str,
851 theirs_id: str,
852 resolution_id: str,
853 ) -> None:
854 plugin = _make_plugin()
855 fp = record_preimage(repo, "hex.mid", ours_id, theirs_id, "midi", plugin)
856 save_resolution(repo, fp, resolution_id)
857 res_p = repo / ".muse" / "rr-cache" / fp / "resolution"
858 content = res_p.read_text(encoding="utf-8").strip()
859 assert content == resolution_id
860 assert len(content) == 64
861 int(content, 16)
862
863 def test_meta_json_is_valid_utf8(
864 self, repo: pathlib.Path, ours_id: str, theirs_id: str
865 ) -> None:
866 plugin = _make_plugin()
867 fp = record_preimage(repo, "utf8.mid", ours_id, theirs_id, "midi", plugin)
868 meta_p = repo / ".muse" / "rr-cache" / fp / "meta.json"
869 data = json.loads(meta_p.read_bytes().decode("utf-8"))
870 assert isinstance(data, dict)
871
872 def test_write_is_atomic(
873 self, repo: pathlib.Path, ours_id: str, theirs_id: str
874 ) -> None:
875 """Atomic write must not leave temp files after success."""
876 plugin = _make_plugin()
877 record_preimage(repo, "atomic.mid", ours_id, theirs_id, "midi", plugin)
878 fp = conflict_fingerprint(ours_id, theirs_id)
879 entry_dir = repo / ".muse" / "rr-cache" / fp
880 temp_files = list(entry_dir.glob(".rr-tmp-*"))
881 assert temp_files == [], f"Unexpected temp files: {temp_files}"