gabriel / muse public
rerere.py python
779 lines 30.8 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """Knowtation domain rerere plugin — Phase 2.5.
2
3 Conflict fingerprinting and replay engine that remembers how note conflicts
4 were resolved and automatically reapplies resolutions when the same logical
5 conflict recurs under cosmetic note rewrites.
6
7 Design overview
8 ---------------
9
10 Two conflicts are *the same* when the **logical content** of the conflicting
11 sections matches, even when the surrounding note has been reformatted —
12 whitespace tweaks, frontmatter tag additions, or section reorders outside
13 the conflict zone. This is achieved by:
14
15 1. **Canonicalising** every input note through
16 :func:`muse.plugins.knowtation.differ.canonicalize` so YAML key order,
17 line endings, and frontmatter dump style are absorbed.
18 2. Splitting the canonical body into Markdown heading sections via
19 :func:`muse.plugins.knowtation.symbols.split_sections` and assigning
20 each section a stable, occurrence-disambiguated ID.
21 3. Computing the conflict tuple set
22 ``(section_id, sha256(ours_body), sha256(theirs_body), sha256(base_body))``
23 only for sections that exist in both ours and theirs **and** whose
24 bodies differ.
25 4. Sorting the tuples by ``section_id`` and folding them into a single
26 SHA-256 digest with a length-prefixed binary encoding so that no
27 section title can smuggle a separator character through the
28 fingerprint.
29
30 Storage layout
31 --------------
32
33 Resolutions live under ``<root>/.muse/rerere/`` keyed by fingerprint:
34
35 * ``<root>/.muse/rerere/<fp[:2]>/<fp[2:]>.json`` — hash-only resolution
36 record consumed by :func:`replay_resolution`.
37 * ``<root>/.muse/rerere/<fp[:2]>/<fp[2:]>.full`` — full canonical bytes of
38 the resolved note consumed by :func:`replay_full_resolution`.
39
40 The two-character shard prevents directory blow-up on vaults with thousands
41 of recorded resolutions. Both files are written atomically via
42 ``tempfile.mkstemp`` + :func:`os.replace`.
43
44 Security model
45 --------------
46
47 * :data:`_MAX_NOTE_BYTES` (16 MiB) hard-caps every byte input and is
48 enforced **before** any parsing — defends against runaway YAML and
49 pathological splitter inputs. Inherits the cap defined by
50 :mod:`muse.plugins.knowtation.differ`.
51 * Fingerprints are validated against :data:`_FINGERPRINT_RE` before any
52 filesystem access — path traversal via ``"../"`` or ``/`` is rejected
53 with :class:`ValueError` rather than silently writing outside the
54 rerere directory.
55 * The canonical fingerprint encoding is **length-prefixed binary**: the
56 section ID is preceded by its UTF-8 byte length packed as a 4-byte
57 big-endian integer, and section body hashes are appended as their raw
58 32-byte digest (decoded from hex). This makes ``|`` / ``:`` injection
59 impossible — a hostile section title cannot create or merge conflict
60 tuples.
61 * Stored resolution JSON uses :func:`json.loads` only; no arbitrary code
62 execution is possible during lookup.
63 * Full-resolution storage is capped at :data:`_MAX_NOTE_BYTES` on read so
64 a corrupted store cannot trigger an OOM during replay.
65
66 Plugin integration
67 ------------------
68
69 This module exposes :class:`KnowtationRererePlugin`, a standalone helper
70 class with the bytes-based interface described in the Phase 2.5 spec.
71 The class is also exported as the :data:`plugin` module-level singleton.
72
73 The runtime-checkable :class:`muse.domain.RererePlugin` protocol used by
74 ``muse rerere`` is satisfied by
75 :class:`muse.plugins.knowtation.plugin.KnowtationPlugin`, which loads the
76 ours / theirs blobs from the local object store and delegates to this
77 module's :func:`conflict_fingerprint`. See ``KnowtationPlugin.conflict_fingerprint``.
78 """
79
80 from __future__ import annotations
81
82 import datetime
83 import hashlib
84 import json
85 import logging
86 import os
87 import pathlib
88 import re
89 import tempfile
90 from typing import Final
91
92 from muse.plugins.knowtation.differ import canonicalize
93 from muse.plugins.knowtation.parser import parse_frontmatter
94 from muse.plugins.knowtation.symbols import _strip_frontmatter, split_sections
95
96 logger = logging.getLogger(__name__)
97
98
99 # ──────────────────────────────────────────────────────────────────────────────
100 # Constants
101 # ──────────────────────────────────────────────────────────────────────────────
102
103 #: Domain slug — matches ``KnowtationPlugin``'s ``_DOMAIN_NAME``.
104 DOMAIN: Final[str] = "knowtation"
105
106 #: 16 MiB — must mirror the cap in :mod:`muse.plugins.knowtation.differ`.
107 #: Inputs larger than this are rejected with :class:`ValueError` *before*
108 #: any parsing to defend against pathological YAML or splitter inputs.
109 _MAX_NOTE_BYTES: Final[int] = 16 * 1024 * 1024
110
111 #: Maximum bytes accepted when reading a stored ``.full`` resolution back
112 #: from disk. Equal to the input cap so a stored resolution cannot exceed
113 #: the size we would have accepted on input.
114 _MAX_FULL_RESOLUTION_BYTES: Final[int] = _MAX_NOTE_BYTES
115
116 #: Maximum bytes accepted when reading a stored ``.json`` resolution-hash
117 #: record back from disk. The hash record is tiny (under 200 bytes); we
118 #: pick 4 KiB to leave room for future metadata without enabling abuse.
119 _MAX_HASH_RECORD_BYTES: Final[int] = 4096
120
121 #: Strict 64-char lowercase hex regex for fingerprint validation. Rejects
122 #: any string containing ``"/"``, ``".."``, or whitespace before it can
123 #: reach :func:`pathlib.Path` and trigger a path-traversal or symlink walk.
124 _FINGERPRINT_RE: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$")
125
126 #: Subdirectory under ``.muse`` where rerere resolutions are stored. This
127 #: is *separate* from the core ``.muse/rr-cache/`` used by
128 #: :mod:`muse.core.rerere` so the two layers do not interfere.
129 _RERERE_DIR: Final[str] = "rerere"
130
131 #: Suffix for the JSON hash-only resolution record.
132 _HASH_SUFFIX: Final[str] = ".json"
133
134 #: Suffix for the full canonical bytes resolution.
135 _FULL_SUFFIX: Final[str] = ".full"
136
137
138 # ──────────────────────────────────────────────────────────────────────────────
139 # Note parsing — section extraction with stable IDs
140 # ──────────────────────────────────────────────────────────────────────────────
141
142
143 def _check_size(label: str, content: bytes) -> None:
144 """Raise :class:`ValueError` if *content* exceeds :data:`_MAX_NOTE_BYTES`.
145
146 Called *before* any parsing of byte input so an oversized adversarial
147 note never reaches the YAML or section parser.
148 """
149 if len(content) > _MAX_NOTE_BYTES:
150 raise ValueError(
151 f"{label} size {len(content)} bytes exceeds maximum "
152 f"{_MAX_NOTE_BYTES} bytes"
153 )
154
155
156 def _section_id(level: int, title: str, occurrence: int) -> str:
157 """Return the occurrence-disambiguated stable section ID.
158
159 Mirrors the convention used by
160 :mod:`muse.plugins.knowtation.differ` and the merger so the same logical
161 section produces the same ID across independent code paths.
162
163 Args:
164 level: Heading level (``0`` for preamble, ``1``–``6`` for H1–H6).
165 title: Heading text without ``#`` characters.
166 occurrence: 0-based count among sections sharing ``(level, title)``.
167
168 Returns:
169 Address string, e.g. ``"section:2:Background#0"``.
170 """
171 return f"section:{level}:{title}#{occurrence}"
172
173
174 def _normalize_section_body(section_body: str) -> bytes:
175 """Return a position-independent canonical form of a section body.
176
177 The :func:`split_sections` algorithm includes the heading line plus
178 every line up to (but not including) the next heading. When a
179 section is reordered within the note its trailing whitespace
180 therefore varies — a section followed by another heading retains a
181 trailing blank line, while the final section retains only a single
182 ``\\n``. Both shapes carry the *same logical content*, so we strip
183 trailing whitespace before hashing to make the fingerprint stable
184 across non-conflicting section reorders.
185
186 Internal blank lines are preserved verbatim — only the last block of
187 whitespace at the end of the section is normalised. The result is
188 encoded to UTF-8 so the caller can hash it directly.
189
190 Args:
191 section_body: Raw section text from :func:`split_sections`.
192
193 Returns:
194 UTF-8 bytes of the section text with trailing whitespace removed
195 and a single ``\\n`` appended (so a body followed by another
196 heading and a body that is the last in the note both end the
197 same way).
198 """
199 return (section_body.rstrip() + "\n").encode("utf-8")
200
201
202 def _section_map(content: bytes) -> dict[str, bytes]:
203 """Parse *content* and return ``{section_id: normalised_body_bytes}``.
204
205 The note is canonicalised first so cosmetic rewrites — CRLF vs LF,
206 YAML key reorderings, alternative quote styles, frontmatter tag
207 additions — do not change the section bodies hashed downstream.
208 Each section body is then run through
209 :func:`_normalize_section_body` so trailing-whitespace differences
210 introduced by the splitter when sections are reordered cannot
211 perturb the fingerprint.
212
213 The frontmatter block itself is **not** included in the map;
214 fingerprinting deliberately ignores frontmatter so a tag addition that
215 leaves every section body untouched does not perturb the fingerprint.
216
217 Args:
218 content: Raw note bytes (any size up to :data:`_MAX_NOTE_BYTES`).
219
220 Returns:
221 Mapping from stable section ID to that section's normalised body
222 bytes. Empty when the note has no sections.
223
224 Raises:
225 ValueError: When *content* exceeds :data:`_MAX_NOTE_BYTES`.
226 """
227 _check_size("note", content)
228 canonical = canonicalize(content)
229 text = canonical.decode("utf-8", errors="replace")
230 body, _ = _strip_frontmatter(text)
231 sections = split_sections(body)
232
233 result: dict[str, bytes] = {}
234 seen: dict[tuple[int, str], int] = {}
235 for level, title, section_body, _line in sections:
236 occurrence = seen.get((level, title), 0)
237 seen[(level, title)] = occurrence + 1
238 sid = _section_id(level, title, occurrence)
239 result[sid] = _normalize_section_body(section_body)
240
241 # Touch parser to ensure the canonical frontmatter is well-formed.
242 # We deliberately discard the result — frontmatter does not enter the
243 # fingerprint by design. This call also exercises the YAML safe_load
244 # path so a malformed frontmatter does not propagate further.
245 parse_frontmatter(canonical)
246
247 return result
248
249
250 # ──────────────────────────────────────────────────────────────────────────────
251 # Public fingerprint
252 # ──────────────────────────────────────────────────────────────────────────────
253
254
255 def _hash_bytes(data: bytes) -> str:
256 """Return SHA-256 hex digest of *data* (UTF-8 hex, lowercase)."""
257 return hashlib.sha256(data).hexdigest()
258
259
260 def conflict_fingerprint(ours: bytes, theirs: bytes, base: bytes) -> str:
261 """Return a stable SHA-256 fingerprint identifying this conflict.
262
263 The fingerprint is computed over the **set of conflicting sections**
264 only — sections present in both *ours* and *theirs* whose canonical
265 bodies differ. Each conflicting section contributes a tuple
266 ``(section_id, sha256(ours_body), sha256(theirs_body), sha256(base_body))``;
267 tuples are sorted by ``section_id`` and folded into a single SHA-256
268 digest with a length-prefixed binary encoding that makes separator-
269 injection attacks impossible.
270
271 Stability properties (proof in module docstring):
272
273 * Cosmetic frontmatter changes (key order, tag additions, quote style)
274 cannot affect the fingerprint because frontmatter is excluded from
275 the section map and the bodies are taken from canonicalised input.
276 * Section reorders outside the conflict zone cannot affect the
277 fingerprint because tuples are sorted by ``section_id``.
278 * CRLF / LF differences cannot affect the fingerprint because
279 :func:`canonicalize` rewrites every line ending to LF before
280 sectioning.
281
282 Commutativity: this function is **not** commutative in ``ours`` and
283 ``theirs`` — swapping them produces a different fingerprint. This is
284 intentional: the resolution recorded for ``(ours, theirs)`` is a
285 different artefact than the resolution recorded for the mirrored
286 conflict. Callers that need commutativity should sort the inputs by
287 ``sha256(canonicalize(side))`` before calling.
288
289 Args:
290 ours: Raw bytes of the ours-side note.
291 theirs: Raw bytes of the theirs-side note.
292 base: Raw bytes of the merge-base note (empty bytes when
293 unavailable — common for the protocol-level call where
294 only ours and theirs are known).
295
296 Returns:
297 64-character lowercase hexadecimal SHA-256 digest.
298
299 Raises:
300 ValueError: When any input exceeds :data:`_MAX_NOTE_BYTES`.
301 """
302 ours_sections = _section_map(ours)
303 theirs_sections = _section_map(theirs)
304 base_sections = _section_map(base) if base else {}
305
306 conflict_sids = sorted(
307 sid
308 for sid in ours_sections
309 if sid in theirs_sections
310 and ours_sections[sid] != theirs_sections[sid]
311 )
312
313 h = hashlib.sha256()
314 for sid in conflict_sids:
315 ours_body = ours_sections[sid]
316 theirs_body = theirs_sections[sid]
317 base_body = base_sections.get(sid, b"")
318
319 sid_bytes = sid.encode("utf-8")
320 # Length-prefixed binary encoding — unconditionally injection-proof.
321 # The 4-byte big-endian length tells us *exactly* how many bytes of
322 # section_id follow, so a malicious title containing "|" or ":" or
323 # "::" cannot merge into a neighbouring tuple. Hash bytes are a
324 # fixed-size 32-byte digest, no separator required.
325 h.update(len(sid_bytes).to_bytes(4, "big"))
326 h.update(sid_bytes)
327 h.update(hashlib.sha256(ours_body).digest())
328 h.update(hashlib.sha256(theirs_body).digest())
329 h.update(hashlib.sha256(base_body).digest())
330
331 return h.hexdigest()
332
333
334 # ──────────────────────────────────────────────────────────────────────────────
335 # Storage primitives
336 # ──────────────────────────────────────────────────────────────────────────────
337
338
339 def _validate_fingerprint(fingerprint: str) -> None:
340 """Raise :class:`ValueError` unless *fingerprint* is 64 lowercase hex chars.
341
342 Defends the rerere directory against path traversal: a fingerprint
343 containing ``"/"`` or ``".."`` would let a caller write outside the
344 intended store, so the validator runs *before* any path construction.
345 """
346 if not isinstance(fingerprint, str) or not _FINGERPRINT_RE.match(fingerprint):
347 raise ValueError(
348 f"Invalid knowtation rerere fingerprint {fingerprint!r} — "
349 "expected exactly 64 lowercase hex characters."
350 )
351
352
353 def _shard_dir(root: pathlib.Path, fingerprint: str) -> pathlib.Path:
354 """Return the per-fingerprint shard directory under ``.muse/rerere/``.
355
356 The directory is sharded by the fingerprint's first two hex chars to
357 keep any single directory under ~256 children even when thousands of
358 resolutions accumulate. Re-validates the fingerprint on every call so
359 the directory cannot escape the rerere root.
360 """
361 _validate_fingerprint(fingerprint)
362 return root / ".muse" / _RERERE_DIR / fingerprint[:2]
363
364
365 def _resolution_path(root: pathlib.Path, fingerprint: str, suffix: str) -> pathlib.Path:
366 """Return the file path for *fingerprint* + *suffix* (``.json`` / ``.full``)."""
367 return _shard_dir(root, fingerprint) / f"{fingerprint[2:]}{suffix}"
368
369
370 def _write_atomic(dest: pathlib.Path, content: bytes) -> None:
371 """Write *content* to *dest* atomically (temp file + rename).
372
373 Uses :func:`tempfile.mkstemp` in *dest*'s parent directory so the
374 rename is a single ``os.replace`` system call — atomic on every
375 POSIX platform and on Windows since 1607. Concurrent readers see
376 either the old file or the complete new file, never a partial write.
377 """
378 dest.parent.mkdir(parents=True, exist_ok=True)
379 fd, tmp_str = tempfile.mkstemp(dir=dest.parent, prefix=".rr-tmp-")
380 tmp = pathlib.Path(tmp_str)
381 try:
382 with os.fdopen(fd, "wb") as fh:
383 fh.write(content)
384 os.replace(tmp, dest)
385 except Exception:
386 tmp.unlink(missing_ok=True)
387 raise
388
389
390 # ──────────────────────────────────────────────────────────────────────────────
391 # Hash-only resolution: record / lookup / replay
392 # ──────────────────────────────────────────────────────────────────────────────
393
394
395 def record_resolution(
396 root: pathlib.Path,
397 fingerprint: str,
398 resolved: bytes,
399 ) -> None:
400 """Record a hash-only resolution for *fingerprint*.
401
402 Stores ``{"fingerprint": fp, "resolved_hash": sha256(canonicalize(resolved)),
403 "recorded_at": iso8601}`` at
404 ``<root>/.muse/rerere/<fp[:2]>/<fp[2:]>.json``. The full resolved
405 bytes are *not* stored — see :func:`record_full_resolution` for the
406 bytes-preserving variant.
407
408 The hash-only record is sufficient for the conservative replay path
409 (:func:`replay_resolution`) which only confirms whether the *current*
410 note already matches a known good resolution.
411
412 Args:
413 root: Repository root (parent of ``.muse/``).
414 fingerprint: 64-char hex fingerprint produced by
415 :func:`conflict_fingerprint`.
416 resolved: Raw bytes of the resolved note.
417
418 Raises:
419 ValueError: When *fingerprint* fails :func:`_validate_fingerprint`
420 or *resolved* exceeds :data:`_MAX_NOTE_BYTES`.
421 """
422 _validate_fingerprint(fingerprint)
423 _check_size("resolved note", resolved)
424
425 canonical = canonicalize(resolved)
426 payload = {
427 "fingerprint": fingerprint,
428 "resolved_hash": _hash_bytes(canonical),
429 "recorded_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
430 }
431 dest = _resolution_path(root, fingerprint, _HASH_SUFFIX)
432 _write_atomic(dest, json.dumps(payload, sort_keys=True, indent=2).encode("utf-8"))
433 logger.debug(
434 "knowtation rerere: recorded hash-only resolution %s (resolved_hash=%s)",
435 fingerprint[:8],
436 payload["resolved_hash"][:8],
437 )
438
439
440 def lookup_resolution(root: pathlib.Path, fingerprint: str) -> str | None:
441 """Return ``resolved_hash`` for *fingerprint* or ``None`` when absent.
442
443 Read-only — never mutates the store. Returns ``None`` when the
444 fingerprint is not recorded, when the JSON record is malformed, or
445 when the record exceeds :data:`_MAX_HASH_RECORD_BYTES`.
446
447 Args:
448 root: Repository root.
449 fingerprint: 64-char hex fingerprint.
450
451 Returns:
452 The stored ``resolved_hash`` (64-char hex) or ``None``.
453
454 Raises:
455 ValueError: When *fingerprint* fails :func:`_validate_fingerprint`.
456 """
457 _validate_fingerprint(fingerprint)
458 path = _resolution_path(root, fingerprint, _HASH_SUFFIX)
459 if not path.exists():
460 return None
461
462 try:
463 size = path.stat().st_size
464 except OSError as exc:
465 logger.warning(
466 "⚠️ knowtation rerere: stat failed on %s: %s", path, exc
467 )
468 return None
469
470 if size > _MAX_HASH_RECORD_BYTES:
471 logger.warning(
472 "⚠️ knowtation rerere: resolution record %s is %d bytes — "
473 "exceeds %d-byte cap; treating as missing",
474 fingerprint[:8],
475 size,
476 _MAX_HASH_RECORD_BYTES,
477 )
478 return None
479
480 try:
481 data = json.loads(path.read_text(encoding="utf-8"))
482 except (OSError, json.JSONDecodeError) as exc:
483 logger.warning(
484 "⚠️ knowtation rerere: failed to read resolution record %s: %s",
485 fingerprint[:8],
486 exc,
487 )
488 return None
489
490 if not isinstance(data, dict):
491 return None
492
493 resolved_hash = data.get("resolved_hash")
494 if not isinstance(resolved_hash, str) or not _FINGERPRINT_RE.match(resolved_hash):
495 # Validate the stored hash too — corrupted disk content must not
496 # silently leak through as a "resolution".
497 return None
498 return resolved_hash
499
500
501 def replay_resolution(
502 root: pathlib.Path,
503 fingerprint: str,
504 current: bytes,
505 ) -> bytes | None:
506 """Conservatively replay a hash-only resolution.
507
508 Looks up the stored ``resolved_hash`` for *fingerprint*; if
509 ``sha256(canonicalize(current)) == resolved_hash`` returns *current*
510 (the note already matches the recorded resolution). Otherwise returns
511 ``None`` because the hash-only record cannot reconstruct the resolved
512 bytes from the hash alone.
513
514 For full byte-level replay use :func:`replay_full_resolution`.
515
516 Args:
517 root: Repository root.
518 fingerprint: 64-char hex fingerprint.
519 current: Current note bytes to be checked against the record.
520
521 Returns:
522 *current* (unchanged) when its canonical SHA-256 matches the
523 recorded ``resolved_hash``; ``None`` otherwise.
524
525 Raises:
526 ValueError: When *fingerprint* fails :func:`_validate_fingerprint`
527 or *current* exceeds :data:`_MAX_NOTE_BYTES`.
528 """
529 _check_size("current note", current)
530 expected_hash = lookup_resolution(root, fingerprint)
531 if expected_hash is None:
532 return None
533 actual_hash = _hash_bytes(canonicalize(current))
534 if actual_hash == expected_hash:
535 return current
536 return None
537
538
539 # ──────────────────────────────────────────────────────────────────────────────
540 # Full-bytes resolution: record / replay
541 # ──────────────────────────────────────────────────────────────────────────────
542
543
544 def record_full_resolution(
545 root: pathlib.Path,
546 fingerprint: str,
547 resolved: bytes,
548 ) -> None:
549 """Record the full canonical bytes of a resolution for *fingerprint*.
550
551 Persists :func:`canonicalize` of *resolved* at
552 ``<root>/.muse/rerere/<fp[:2]>/<fp[2:]>.full`` so that
553 :func:`replay_full_resolution` can return the resolved bytes directly
554 without needing the user to re-resolve. This is the variant the
555 rerere CLI uses when auto-resolving a recurring conflict.
556
557 Args:
558 root: Repository root.
559 fingerprint: 64-char hex fingerprint.
560 resolved: Raw resolved-note bytes.
561
562 Raises:
563 ValueError: When *fingerprint* fails :func:`_validate_fingerprint`
564 or *resolved* exceeds :data:`_MAX_NOTE_BYTES`.
565 """
566 _validate_fingerprint(fingerprint)
567 _check_size("resolved note", resolved)
568
569 canonical = canonicalize(resolved)
570 dest = _resolution_path(root, fingerprint, _FULL_SUFFIX)
571 _write_atomic(dest, canonical)
572 logger.debug(
573 "knowtation rerere: recorded full resolution %s (%d bytes canonical)",
574 fingerprint[:8],
575 len(canonical),
576 )
577
578
579 def replay_full_resolution(
580 root: pathlib.Path,
581 fingerprint: str,
582 current: bytes,
583 ) -> bytes | None:
584 """Return the previously recorded full-bytes resolution, or ``None``.
585
586 Reads ``<root>/.muse/rerere/<fp[:2]>/<fp[2:]>.full`` and returns its
587 bytes verbatim (the canonicalised resolved note). Returns ``None``
588 when the file is absent, unreadable, or exceeds
589 :data:`_MAX_FULL_RESOLUTION_BYTES`.
590
591 The *current* parameter is accepted for API parity with
592 :func:`replay_resolution` and as a hook for future evolution (e.g. a
593 three-way merge against the stored resolution). In Phase 2.5 it is
594 deliberately unused: byte-perfect replay always returns the recorded
595 canonical resolution regardless of what *current* contains.
596
597 Args:
598 root: Repository root.
599 fingerprint: 64-char hex fingerprint.
600 current: Current note bytes (size-checked but otherwise
601 unused — see note above).
602
603 Returns:
604 Canonical resolved-note bytes from the store, or ``None`` when no
605 full resolution is recorded for *fingerprint*.
606
607 Raises:
608 ValueError: When *fingerprint* fails :func:`_validate_fingerprint`
609 or *current* exceeds :data:`_MAX_NOTE_BYTES`.
610 """
611 _check_size("current note", current)
612 _validate_fingerprint(fingerprint)
613 path = _resolution_path(root, fingerprint, _FULL_SUFFIX)
614 if not path.exists():
615 return None
616
617 try:
618 size = path.stat().st_size
619 except OSError as exc:
620 logger.warning(
621 "⚠️ knowtation rerere: stat failed on %s: %s", path, exc
622 )
623 return None
624
625 if size > _MAX_FULL_RESOLUTION_BYTES:
626 logger.warning(
627 "⚠️ knowtation rerere: full resolution %s is %d bytes — "
628 "exceeds %d-byte cap; refusing to replay",
629 fingerprint[:8],
630 size,
631 _MAX_FULL_RESOLUTION_BYTES,
632 )
633 return None
634
635 try:
636 return path.read_bytes()
637 except OSError as exc:
638 logger.warning(
639 "⚠️ knowtation rerere: failed to read full resolution %s: %s",
640 fingerprint[:8],
641 exc,
642 )
643 return None
644
645
646 # ──────────────────────────────────────────────────────────────────────────────
647 # Plugin class
648 # ──────────────────────────────────────────────────────────────────────────────
649
650
651 class KnowtationRererePlugin:
652 """Domain-aware rerere plugin for knowtation note conflicts.
653
654 Standalone helper class with the bytes-based interface required by
655 Phase 2.5. All methods delegate to the module-level functions of
656 the same name so the plugin instance is a thin, stateless façade.
657
658 The CLI integration (``muse rerere``) is achieved separately by
659 :class:`muse.plugins.knowtation.plugin.KnowtationPlugin`, which
660 implements the runtime-checkable
661 :class:`muse.domain.RererePlugin` protocol method
662 ``conflict_fingerprint(path, ours_id, theirs_id, repo_root)`` and
663 delegates to :func:`conflict_fingerprint` after loading the ours /
664 theirs blobs from the local object store.
665
666 Thread safety: every method is independent of mutable state. Multiple
667 threads may call any method concurrently; collisions on the rerere
668 store are resolved by the atomic write pattern in :func:`_write_atomic`.
669 """
670
671 #: Domain slug — used by orchestrators that key plugins by name.
672 domain: str = DOMAIN
673
674 # ------------------------------------------------------------------
675 # Fingerprinting
676 # ------------------------------------------------------------------
677
678 def conflict_fingerprint(
679 self,
680 ours: bytes,
681 theirs: bytes,
682 base: bytes,
683 ) -> str:
684 """Return the bytes-based knowtation conflict fingerprint.
685
686 Thin delegate to the module-level :func:`conflict_fingerprint`.
687 See that function's docstring for stability guarantees and edge
688 cases.
689
690 Args:
691 ours: Raw ours-side note bytes.
692 theirs: Raw theirs-side note bytes.
693 base: Raw merge-base note bytes (or ``b""`` when unknown).
694
695 Returns:
696 64-char lowercase hex SHA-256 fingerprint.
697
698 Raises:
699 ValueError: When any input exceeds :data:`_MAX_NOTE_BYTES`.
700 """
701 return conflict_fingerprint(ours, theirs, base)
702
703 # ------------------------------------------------------------------
704 # Hash-only resolution
705 # ------------------------------------------------------------------
706
707 def record_resolution(
708 self,
709 root: pathlib.Path,
710 fingerprint: str,
711 resolved: bytes,
712 ) -> None:
713 """Persist a hash-only resolution record — see
714 :func:`record_resolution`.
715 """
716 record_resolution(root, fingerprint, resolved)
717
718 def lookup_resolution(
719 self,
720 root: pathlib.Path,
721 fingerprint: str,
722 ) -> str | None:
723 """Return the recorded resolved-hash or ``None`` — see
724 :func:`lookup_resolution`.
725 """
726 return lookup_resolution(root, fingerprint)
727
728 def replay_resolution(
729 self,
730 root: pathlib.Path,
731 fingerprint: str,
732 current: bytes,
733 ) -> bytes | None:
734 """Conservative hash-only replay — see :func:`replay_resolution`."""
735 return replay_resolution(root, fingerprint, current)
736
737 # ------------------------------------------------------------------
738 # Full-bytes resolution
739 # ------------------------------------------------------------------
740
741 def record_full_resolution(
742 self,
743 root: pathlib.Path,
744 fingerprint: str,
745 resolved: bytes,
746 ) -> None:
747 """Persist the canonical resolved bytes — see
748 :func:`record_full_resolution`.
749 """
750 record_full_resolution(root, fingerprint, resolved)
751
752 def replay_full_resolution(
753 self,
754 root: pathlib.Path,
755 fingerprint: str,
756 current: bytes,
757 ) -> bytes | None:
758 """Return the canonical resolved bytes — see
759 :func:`replay_full_resolution`.
760 """
761 return replay_full_resolution(root, fingerprint, current)
762
763
764 #: Module-level singleton — used by the registry hook in
765 #: :mod:`muse.plugins.knowtation.plugin`.
766 plugin: Final[KnowtationRererePlugin] = KnowtationRererePlugin()
767
768
769 __all__ = [
770 "DOMAIN",
771 "KnowtationRererePlugin",
772 "conflict_fingerprint",
773 "lookup_resolution",
774 "plugin",
775 "record_full_resolution",
776 "record_resolution",
777 "replay_full_resolution",
778 "replay_resolution",
779 ]
File History 1 commit
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago