rerere.py
python
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