gabriel / muse public
test_rerere.py python
881 lines 29.4 KB
86000da9 fix: replace typer CliRunner with argparse-compatible test helper Gabriel Cardona <gabriel@tellurstori.com> 1d 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 tests.cli_test_helper import CliRunner
20
21 cli = None # argparse migration — CliRunner ignores this arg
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}"