rerere.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """Rerere β€” Reuse Recorded Resolution for Muse VCS conflicts.
2
3 Records how a user resolves a merge conflict, keyed by a deterministic
4 content fingerprint, so that identical conflicts are resolved automatically
5 on future merges. Analogous to ``git rerere`` but domain-agnostic and
6 extended to support semantic fingerprinting via the optional
7 :class:`~muse.domain.RererePlugin` sub-protocol.
8
9 Storage layout
10 --------------
11
12 Each cached resolution lives under ``.muse/rr-cache/<fingerprint>/``::
13
14 meta.json β€” conflict metadata (path, ours_id, theirs_id, domain, recorded_at)
15 resolution β€” 64-char hex SHA-256 of the resolved blob
16
17 Both files use UTF-8 encoding. Writes are atomic (temp file + ``os.replace``).
18
19 Fingerprinting
20 --------------
21
22 The default fingerprint is::
23
24 SHA-256( min(ours_id, theirs_id) + ":" + max(ours_id, theirs_id) )
25
26 Content-addressed IDs are already SHA-256 hashes; sorting ensures
27 commutativity β€” resolving A vs B is identical to resolving B vs A.
28
29 Plugins may implement :class:`~muse.domain.RererePlugin` to provide
30 semantically richer fingerprints. For example, a MIDI plugin can hash
31 note-event structure rather than raw blob IDs, so the same musical conflict
32 is recognised even when surrounding timing context shifts.
33
34 Thread safety
35 -------------
36
37 Every write uses ``os.replace`` (POSIX rename), which is atomic on all
38 supported platforms. Concurrent readers always see a complete or absent file,
39 never a partial write.
40
41 Security model
42 --------------
43
44 Fingerprint validation: every fingerprint that reaches :func:`_entry_dir` is
45 checked against ``_FINGERPRINT_RE`` (exactly 64 lowercase hex chars).
46 Crafted directory names in ``.muse/rr-cache/`` that are not valid fingerprints
47 are skipped silently by :func:`list_records` and :func:`load_record`.
48
49 File size caps: :func:`load_record` enforces ``_MAX_META_BYTES`` before
50 reading ``meta.json`` and ``_MAX_RESOLUTION_BYTES`` before reading the
51 ``resolution`` file. This prevents OOM from maliciously large files.
52
53 Symlink guards: :func:`list_records` and :func:`clear_all` skip symlinks
54 when iterating the rr-cache directory β€” only regular directories are
55 processed.
56
57 Path traversal: :func:`auto_apply` validates that each conflict path stays
58 within the repository root before constructing the destination path.
59 """
60 from __future__ import annotations
61
62 import datetime
63 import hashlib
64 import json
65 import logging
66 import os
67 import pathlib
68 import re
69 import tempfile
70 from dataclasses import dataclass
71 from typing import TYPE_CHECKING, TypedDict
72
73 from muse.core.store import Manifest
74 from muse.core.validation import validate_object_id
75
76 if TYPE_CHECKING:
77 from muse.domain import MuseDomainPlugin
78
79 logger = logging.getLogger(__name__)
80
81 # ---------------------------------------------------------------------------
82 # Constants
83 # ---------------------------------------------------------------------------
84
85 _RR_CACHE = "rr-cache"
86 _META_FILE = "meta.json"
87 _RESOLUTION_FILE = "resolution"
88
89 #: Maximum number of rr-cache entries scanned by list_records() / gc().
90 _MAX_SCAN = 100_000
91
92 #: File size cap for meta.json reads (meta is always tiny β€” < 1 KiB).
93 _MAX_META_BYTES: int = 4_096
94
95 #: File size cap for resolution file reads (exactly 64 hex chars + newline).
96 _MAX_RESOLUTION_BYTES: int = 128
97
98 #: Regex that a valid fingerprint must match β€” 64 lowercase hex chars.
99 _FINGERPRINT_RE: re.Pattern[str] = re.compile(r"^[0-9a-f]{64}$")
100
101
102 # ---------------------------------------------------------------------------
103 # Wire-format TypedDict for meta.json
104 # ---------------------------------------------------------------------------
105
106
107 class RerereMetaDict(TypedDict):
108 """JSON-serialisable form of a rerere preimage record."""
109
110 fingerprint: str
111 path: str
112 ours_id: str
113 theirs_id: str
114 domain: str
115 recorded_at: str # ISO 8601
116
117
118 # ---------------------------------------------------------------------------
119 # Public dataclass returned to callers
120 # ---------------------------------------------------------------------------
121
122
123 @dataclass(frozen=True)
124 class RerereRecord:
125 """A cached conflict resolution entry.
126
127 ``fingerprint`` β€” Hex fingerprint that identifies the conflict.
128 ``path`` β€” Workspace-relative POSIX path that conflicted.
129 ``ours_id`` β€” Object ID of the "ours" version at conflict time.
130 ``theirs_id`` β€” Object ID of the "theirs" version at conflict time.
131 ``domain`` β€” Domain name that produced the conflict.
132 ``recorded_at`` β€” When the preimage was recorded.
133 ``resolution_id`` β€” Object ID of the chosen resolution, or ``None`` when
134 the user has not yet committed a resolution.
135 """
136
137 fingerprint: str
138 path: str
139 ours_id: str
140 theirs_id: str
141 domain: str
142 recorded_at: datetime.datetime
143 resolution_id: str | None = None
144
145 @property
146 def has_resolution(self) -> bool:
147 """``True`` when a resolution blob has been recorded."""
148 return self.resolution_id is not None
149
150
151 # ---------------------------------------------------------------------------
152 # Directory helpers
153 # ---------------------------------------------------------------------------
154
155
156 def rr_cache_dir(root: pathlib.Path) -> pathlib.Path:
157 """Return the path to ``.muse/rr-cache/`` (may not yet exist)."""
158 return root / ".muse" / _RR_CACHE
159
160
161 def _validate_fingerprint(fingerprint: str) -> None:
162 """Raise ``ValueError`` if *fingerprint* is not 64 lowercase hex chars.
163
164 Prevents path-traversal attacks where a crafted fingerprint like
165 ``"../evil"`` would escape the rr-cache directory.
166 """
167 if not _FINGERPRINT_RE.match(fingerprint):
168 raise ValueError(
169 f"Invalid rerere fingerprint {fingerprint!r} β€” "
170 "expected exactly 64 lowercase hex characters."
171 )
172
173
174 def _entry_dir(root: pathlib.Path, fingerprint: str) -> pathlib.Path:
175 """Return the rr-cache entry directory for *fingerprint*.
176
177 Raises ``ValueError`` if *fingerprint* is not a valid 64-char hex string.
178 """
179 _validate_fingerprint(fingerprint)
180 return rr_cache_dir(root) / fingerprint
181
182
183 def _meta_path(root: pathlib.Path, fingerprint: str) -> pathlib.Path:
184 return _entry_dir(root, fingerprint) / _META_FILE
185
186
187 def _resolution_path(root: pathlib.Path, fingerprint: str) -> pathlib.Path:
188 return _entry_dir(root, fingerprint) / _RESOLUTION_FILE
189
190
191 # ---------------------------------------------------------------------------
192 # Fingerprinting
193 # ---------------------------------------------------------------------------
194
195
196 def conflict_fingerprint(ours_id: str, theirs_id: str) -> str:
197 """Return the default content fingerprint for a conflict.
198
199 Hashes the sorted pair of object IDs so the result is the same regardless
200 of which branch is "ours" vs "theirs".
201
202 Args:
203 ours_id: SHA-256 object ID of the "ours" version.
204 theirs_id: SHA-256 object ID of the "theirs" version.
205
206 Returns:
207 64-char lowercase hex SHA-256 fingerprint.
208 """
209 lo, hi = sorted((ours_id, theirs_id))
210 canonical = f"{lo}:{hi}"
211 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
212
213
214 def compute_fingerprint(
215 path: str,
216 ours_id: str,
217 theirs_id: str,
218 plugin: MuseDomainPlugin,
219 repo_root: pathlib.Path,
220 ) -> str:
221 """Return the fingerprint for a conflict, preferring plugin semantics.
222
223 Checks whether *plugin* implements the optional
224 :class:`~muse.domain.RererePlugin` sub-protocol. When it does the
225 plugin's domain-aware fingerprint is used, giving richer matching (e.g.
226 note-event structure for MIDI rather than raw blob hashes). Otherwise
227 falls back to the default content fingerprint.
228
229 Args:
230 path: Workspace-relative POSIX path of the conflicting file.
231 ours_id: Object ID of the "ours" blob.
232 theirs_id: Object ID of the "theirs" blob.
233 plugin: Active domain plugin instance.
234 repo_root: Repository root for plugin context.
235
236 Returns:
237 64-char hex fingerprint.
238 """
239 from muse.domain import RererePlugin
240
241 if isinstance(plugin, RererePlugin):
242 try:
243 fp = plugin.conflict_fingerprint(path, ours_id, theirs_id, repo_root)
244 if fp and len(fp) == 64:
245 return fp
246 logger.warning(
247 "⚠️ Plugin conflict_fingerprint returned invalid value %r β€” "
248 "falling back to default fingerprint",
249 fp,
250 )
251 except Exception as exc: # noqa: BLE001
252 logger.warning(
253 "⚠️ Plugin conflict_fingerprint raised %s β€” "
254 "falling back to default fingerprint",
255 exc,
256 )
257 return conflict_fingerprint(ours_id, theirs_id)
258
259
260 # ---------------------------------------------------------------------------
261 # Atomic write helper
262 # ---------------------------------------------------------------------------
263
264
265 def _write_atomic(dest: pathlib.Path, content: str) -> None:
266 """Write *content* to *dest* atomically via a temp file + rename."""
267 dest.parent.mkdir(parents=True, exist_ok=True)
268 fd, tmp_str = tempfile.mkstemp(dir=dest.parent, prefix=".rr-tmp-")
269 tmp = pathlib.Path(tmp_str)
270 try:
271 with os.fdopen(fd, "w", encoding="utf-8") as fh:
272 fh.write(content)
273 os.replace(tmp, dest)
274 except Exception:
275 tmp.unlink(missing_ok=True)
276 raise
277
278
279 # ---------------------------------------------------------------------------
280 # Record / save / load
281 # ---------------------------------------------------------------------------
282
283
284 def record_preimage(
285 root: pathlib.Path,
286 path: str,
287 ours_id: str,
288 theirs_id: str,
289 domain: str,
290 plugin: MuseDomainPlugin,
291 ) -> str:
292 """Record the preimage of a conflict so it can be replayed later.
293
294 Writes ``.muse/rr-cache/<fingerprint>/meta.json`` but does NOT write a
295 resolution (that happens after the user commits). Idempotent: re-recording
296 the same conflict is a no-op.
297
298 Args:
299 root: Repository root.
300 path: Workspace-relative POSIX path of the conflicting file.
301 ours_id: SHA-256 of the "ours" blob at conflict time.
302 theirs_id: SHA-256 of the "theirs" blob at conflict time.
303 domain: Active domain name (e.g. ``"midi"``, ``"code"``).
304 plugin: Active domain plugin (for optional fingerprint override).
305
306 Returns:
307 The 64-char hex fingerprint identifying this conflict.
308 """
309 validate_object_id(ours_id)
310 validate_object_id(theirs_id)
311
312 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
313 meta_p = _meta_path(root, fp)
314
315 if meta_p.exists():
316 logger.debug("rerere: preimage %s already recorded for '%s'", fp[:8], path)
317 return fp
318
319 meta: RerereMetaDict = {
320 "fingerprint": fp,
321 "path": path,
322 "ours_id": ours_id,
323 "theirs_id": theirs_id,
324 "domain": domain,
325 "recorded_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
326 }
327 _write_atomic(meta_p, json.dumps(meta, indent=2))
328 logger.debug("rerere: recorded preimage %s for '%s'", fp[:8], path)
329 return fp
330
331
332 def save_resolution(root: pathlib.Path, fingerprint: str, resolution_id: str) -> None:
333 """Persist the object ID of the chosen resolution for a conflict.
334
335 Written after the user resolves and commits. Idempotent if the same
336 resolution is saved twice.
337
338 Args:
339 root: Repository root.
340 fingerprint: 64-char hex fingerprint from :func:`record_preimage`.
341 resolution_id: SHA-256 of the resolved blob.
342
343 Raises:
344 FileNotFoundError: When no preimage exists for *fingerprint*.
345 ValueError: When *resolution_id* is not a valid object ID or
346 *fingerprint* is not a valid hex fingerprint.
347 """
348 validate_object_id(resolution_id)
349
350 meta_p = _meta_path(root, fingerprint)
351 if not meta_p.exists():
352 raise FileNotFoundError(
353 f"No rerere preimage found for fingerprint {fingerprint[:8]}. "
354 "Run 'muse rerere record' first."
355 )
356
357 res_p = _resolution_path(root, fingerprint)
358 _write_atomic(res_p, resolution_id)
359 logger.debug(
360 "rerere: saved resolution %s for fingerprint %s",
361 resolution_id[:8],
362 fingerprint[:8],
363 )
364
365
366 def load_record(root: pathlib.Path, fingerprint: str) -> RerereRecord | None:
367 """Load a :class:`RerereRecord` from disk.
368
369 Returns ``None`` when the fingerprint is not in the cache, when the
370 fingerprint is not a valid 64-char hex string, or when the meta.json
371 file cannot be read or parsed.
372
373 Args:
374 root: Repository root.
375 fingerprint: 64-char hex fingerprint.
376
377 Returns:
378 :class:`RerereRecord` or ``None``.
379
380 Notes:
381 ``meta.json`` is capped at ``_MAX_META_BYTES`` and the ``resolution``
382 file at ``_MAX_RESOLUTION_BYTES`` to prevent OOM from maliciously
383 large files.
384 """
385 try:
386 meta_p = _meta_path(root, fingerprint)
387 except ValueError:
388 # fingerprint is not a valid 64-char hex string β€” skip silently.
389 return None
390
391 if not meta_p.exists():
392 return None
393
394 try:
395 size = meta_p.stat().st_size
396 if size > _MAX_META_BYTES:
397 logger.warning(
398 "⚠️ rerere: meta.json for %s is %d bytes β€” exceeds cap of %d; skipping",
399 fingerprint[:8],
400 size,
401 _MAX_META_BYTES,
402 )
403 return None
404 data = json.loads(meta_p.read_text(encoding="utf-8"))
405 except (OSError, json.JSONDecodeError) as exc:
406 logger.warning("⚠️ Failed to read rerere meta %s: %s", meta_p, exc)
407 return None
408
409 try:
410 recorded_at = datetime.datetime.fromisoformat(str(data.get("recorded_at", "")))
411 except ValueError:
412 recorded_at = datetime.datetime.now(datetime.timezone.utc)
413
414 resolution_id: str | None = None
415 try:
416 res_p = _resolution_path(root, fingerprint)
417 except ValueError:
418 res_p = None # fingerprint invalid β€” already handled above, but be safe
419
420 if res_p is not None and res_p.exists():
421 try:
422 res_size = res_p.stat().st_size
423 if res_size <= _MAX_RESOLUTION_BYTES:
424 raw = res_p.read_text(encoding="utf-8").strip()
425 validate_object_id(raw)
426 resolution_id = raw
427 else:
428 logger.warning(
429 "⚠️ rerere: resolution file for %s is %d bytes β€” too large; ignoring",
430 fingerprint[:8],
431 res_size,
432 )
433 except (OSError, ValueError):
434 resolution_id = None
435
436 return RerereRecord(
437 fingerprint=fingerprint,
438 path=str(data.get("path", "")),
439 ours_id=str(data.get("ours_id", "")),
440 theirs_id=str(data.get("theirs_id", "")),
441 domain=str(data.get("domain", "")),
442 recorded_at=recorded_at,
443 resolution_id=resolution_id,
444 )
445
446
447 # ---------------------------------------------------------------------------
448 # Resolution application
449 # ---------------------------------------------------------------------------
450
451
452 def has_resolution(root: pathlib.Path, fingerprint: str) -> bool:
453 """Return ``True`` when a resolution blob has been saved for *fingerprint*."""
454 try:
455 return _resolution_path(root, fingerprint).exists()
456 except ValueError:
457 return False
458
459
460 def apply_cached(
461 root: pathlib.Path,
462 fingerprint: str,
463 dest: pathlib.Path,
464 ) -> bool:
465 """Restore the cached resolution blob to *dest* in the working tree.
466
467 Args:
468 root: Repository root.
469 fingerprint: Conflict fingerprint.
470 dest: Absolute path to write the restored file.
471
472 Returns:
473 ``True`` on success, ``False`` when there is no cached resolution or
474 the resolution blob is not in the local object store.
475
476 Notes:
477 The ``resolution`` file is capped at ``_MAX_RESOLUTION_BYTES`` before
478 reading to prevent OOM from maliciously large files.
479 """
480 try:
481 res_p = _resolution_path(root, fingerprint)
482 except ValueError:
483 logger.warning("⚠️ rerere: invalid fingerprint in apply_cached: %r", fingerprint)
484 return False
485
486 if not res_p.exists():
487 return False
488
489 try:
490 res_size = res_p.stat().st_size
491 if res_size > _MAX_RESOLUTION_BYTES:
492 logger.warning(
493 "⚠️ rerere: resolution file for %s is %d bytes β€” too large; refusing to apply",
494 fingerprint[:8],
495 res_size,
496 )
497 return False
498 resolution_id = res_p.read_text(encoding="utf-8").strip()
499 validate_object_id(resolution_id)
500 except (OSError, ValueError) as exc:
501 logger.warning("⚠️ rerere: invalid resolution file for %s: %s", fingerprint[:8], exc)
502 return False
503
504 from muse.core.object_store import has_object, restore_object
505
506 if not has_object(root, resolution_id):
507 logger.warning(
508 "⚠️ rerere: resolution blob %s for %s not in local store β€” "
509 "cannot auto-apply (was this rr-cache transferred from another machine?)",
510 resolution_id[:8],
511 fingerprint[:8],
512 )
513 return False
514
515 dest.parent.mkdir(parents=True, exist_ok=True)
516 ok = restore_object(root, resolution_id, dest)
517 if ok:
518 logger.debug(
519 "rerere: applied resolution %s β†’ %s", resolution_id[:8], dest.name
520 )
521 return ok
522
523
524 # ---------------------------------------------------------------------------
525 # Bulk operations: list, forget, clear, gc
526 # ---------------------------------------------------------------------------
527
528
529 def list_records(root: pathlib.Path) -> list[RerereRecord]:
530 """Return all :class:`RerereRecord` entries in the rr-cache.
531
532 Scans ``.muse/rr-cache/`` and loads each entry. Skips unreadable entries,
533 non-fingerprint directories, and symlinks with a warning. Capped at
534 :data:`_MAX_SCAN` entries to protect against degenerate repos.
535
536 Args:
537 root: Repository root.
538
539 Returns:
540 List of :class:`RerereRecord`, sorted by ``recorded_at`` descending
541 (most recent first).
542 """
543 cache_dir = rr_cache_dir(root)
544 if not cache_dir.exists():
545 return []
546
547 records: list[RerereRecord] = []
548 count = 0
549 for entry in cache_dir.iterdir():
550 if count >= _MAX_SCAN:
551 logger.warning(
552 "⚠️ rerere: rr-cache has more than %d entries β€” scan truncated",
553 _MAX_SCAN,
554 )
555 break
556 count += 1
557 # Skip symlinks β€” symlinks cannot be valid rr-cache entries and a
558 # crafted symlink could point outside .muse/.
559 if entry.is_symlink():
560 logger.debug("rerere: skipping symlink %s in rr-cache", entry.name)
561 continue
562 if not entry.is_dir():
563 continue
564 record = load_record(root, entry.name)
565 if record is not None:
566 records.append(record)
567
568 records.sort(key=lambda r: r.recorded_at, reverse=True)
569 return records
570
571
572 def forget_record(root: pathlib.Path, fingerprint: str) -> bool:
573 """Remove the rr-cache entry for *fingerprint*.
574
575 Deletes both ``meta.json`` and ``resolution`` (if present), then removes
576 the entry directory if empty.
577
578 Args:
579 root: Repository root.
580 fingerprint: 64-char hex fingerprint to forget.
581
582 Returns:
583 ``True`` if the entry existed and was removed, ``False`` otherwise.
584 Also returns ``False`` if *fingerprint* is not a valid hex fingerprint.
585 """
586 try:
587 entry = _entry_dir(root, fingerprint)
588 except ValueError:
589 logger.warning("⚠️ rerere: invalid fingerprint in forget_record: %r", fingerprint)
590 return False
591
592 if not entry.exists():
593 return False
594
595 for child in entry.iterdir():
596 child.unlink(missing_ok=True)
597 try:
598 entry.rmdir()
599 except OSError:
600 pass # not empty β€” leave it
601
602 logger.debug("rerere: forgot resolution for fingerprint %s", fingerprint[:8])
603 return True
604
605
606 def clear_all(root: pathlib.Path) -> int:
607 """Remove every entry from the rr-cache.
608
609 Args:
610 root: Repository root.
611
612 Returns:
613 Number of entries removed.
614
615 Notes:
616 Symlinks inside the rr-cache directory are skipped β€” only real
617 subdirectories are cleared.
618 """
619 cache_dir = rr_cache_dir(root)
620 if not cache_dir.exists():
621 return 0
622
623 removed = 0
624 for entry in cache_dir.iterdir():
625 # Skip symlinks β€” we only delete real rr-cache entry directories.
626 if entry.is_symlink():
627 continue
628 if not entry.is_dir():
629 continue
630 for child in entry.iterdir():
631 child.unlink(missing_ok=True)
632 try:
633 entry.rmdir()
634 removed += 1
635 except OSError:
636 pass
637
638 logger.debug("rerere: cleared %d rr-cache entries", removed)
639 return removed
640
641
642 def gc_stale(root: pathlib.Path, age_days: int = 60) -> int:
643 """Remove preimage-only entries older than *age_days* (no resolution recorded).
644
645 Keeps all entries that have a resolution regardless of age. This mirrors
646 Git's ``git rerere gc`` behaviour.
647
648 Args:
649 root: Repository root.
650 age_days: Entries without a resolution older than this many days are
651 removed. Defaults to 60.
652
653 Returns:
654 Number of entries removed.
655 """
656 cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=age_days)
657 records = list_records(root)
658 removed = 0
659 for rec in records:
660 if rec.has_resolution:
661 continue
662 if rec.recorded_at < cutoff:
663 if forget_record(root, rec.fingerprint):
664 removed += 1
665 logger.debug("rerere gc: removed %d stale preimage-only entries", removed)
666 return removed
667
668
669 # ---------------------------------------------------------------------------
670 # High-level integration helpers called by merge and commit commands
671 # ---------------------------------------------------------------------------
672
673
674 def auto_apply(
675 root: pathlib.Path,
676 conflict_paths: list[str],
677 ours_manifest: Manifest,
678 theirs_manifest: Manifest,
679 domain: str,
680 plugin: MuseDomainPlugin,
681 ) -> tuple[dict[str, str], list[str]]:
682 """Attempt to auto-resolve conflicts using cached rerere resolutions.
683
684 For each *conflict_paths* entry:
685
686 1. Validate the path stays within the repository root (path traversal guard).
687 2. Compute the fingerprint from the ours/theirs object IDs.
688 3. Check whether a resolution has been saved for that fingerprint.
689 4. If yes: restore the resolution blob to the working tree path and
690 collect the resolved ``{path: resolution_id}`` mapping.
691 5. If no: leave the conflict for manual resolution and record the preimage.
692
693 Args:
694 root: Repository root.
695 conflict_paths: Workspace-relative POSIX paths that conflicted.
696 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
697 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
698 domain: Active domain name string.
699 plugin: Active domain plugin instance.
700
701 Returns:
702 ``(resolved, remaining)`` where:
703
704 - ``resolved`` maps path β†’ resolution_object_id for auto-resolved paths.
705 - ``remaining`` is the list of paths still requiring manual resolution.
706 """
707 resolved: Manifest = {}
708 remaining: list[str] = []
709
710 for path in conflict_paths:
711 # Guard against path traversal from a tampered MERGE_STATE.json.
712 try:
713 (root / path).relative_to(root)
714 except ValueError:
715 logger.warning(
716 "⚠️ rerere: path traversal attempt in auto_apply β€” skipping %r",
717 path,
718 )
719 remaining.append(path)
720 continue
721
722 ours_id = ours_manifest.get(path, "")
723 theirs_id = theirs_manifest.get(path, "")
724
725 if not ours_id or not theirs_id:
726 # One side deleted the file β€” cannot fingerprint, leave for manual.
727 remaining.append(path)
728 continue
729
730 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
731
732 if has_resolution(root, fp):
733 # Load resolution_id before restoring so we can add it to the manifest.
734 try:
735 res_p = _resolution_path(root, fp)
736 except ValueError:
737 remaining.append(path)
738 continue
739 try:
740 res_size = res_p.stat().st_size
741 if res_size > _MAX_RESOLUTION_BYTES:
742 remaining.append(path)
743 continue
744 resolution_id = res_p.read_text(encoding="utf-8").strip()
745 validate_object_id(resolution_id)
746 except (OSError, ValueError):
747 remaining.append(path)
748 continue
749
750 dest = root / path
751 if apply_cached(root, fp, dest):
752 resolved[path] = resolution_id
753 logger.info("βœ… rerere: auto-resolved '%s'", path)
754 else:
755 # Resolution blob missing from store β€” fall back to manual.
756 remaining.append(path)
757 else:
758 # No cached resolution β€” record preimage for future use.
759 record_preimage(root, path, ours_id, theirs_id, domain, plugin)
760 remaining.append(path)
761
762 return resolved, remaining
763
764
765 def record_resolutions(
766 root: pathlib.Path,
767 conflict_paths: list[str],
768 ours_manifest: Manifest,
769 theirs_manifest: Manifest,
770 new_manifest: Manifest,
771 domain: str,
772 plugin: MuseDomainPlugin,
773 ) -> list[str]:
774 """Record how the user resolved each conflict after a merge commit.
775
776 Called by ``muse commit`` immediately after writing a merge commit that
777 resolves a conflicted merge. For each previously conflicting path the
778 function reads the resolution object ID from the new snapshot manifest and
779 saves it to ``.muse/rr-cache/<fingerprint>/resolution``.
780
781 Args:
782 root: Repository root.
783 conflict_paths: Paths that were listed as conflicts in MERGE_STATE.
784 ours_manifest: ``{path: object_id}`` for the "ours" snapshot.
785 theirs_manifest: ``{path: object_id}`` for the "theirs" snapshot.
786 new_manifest: ``{path: object_id}`` from the committed merge snapshot.
787 domain: Active domain name string.
788 plugin: Active domain plugin instance.
789
790 Returns:
791 List of paths for which a resolution was successfully saved.
792 """
793 saved: list[str] = []
794
795 for path in conflict_paths:
796 ours_id = ours_manifest.get(path, "")
797 theirs_id = theirs_manifest.get(path, "")
798 resolution_id = new_manifest.get(path, "")
799
800 if not ours_id or not theirs_id or not resolution_id:
801 # Cannot record: one side deleted the file or not in new snapshot.
802 continue
803
804 try:
805 validate_object_id(resolution_id)
806 except ValueError:
807 continue
808
809 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
810
811 # Ensure the preimage exists (it may have been written by auto_apply).
812 if not _meta_path(root, fp).exists():
813 record_preimage(root, path, ours_id, theirs_id, domain, plugin)
814
815 try:
816 save_resolution(root, fp, resolution_id)
817 saved.append(path)
818 logger.info(
819 "βœ… rerere: recorded resolution for '%s' (fingerprint %s)",
820 path,
821 fp[:8],
822 )
823 except (FileNotFoundError, ValueError) as exc:
824 logger.warning("⚠️ rerere: failed to save resolution for '%s': %s", path, exc)
825
826 return saved