merger.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """Knowtation three-way merger — Phase 2.4. |
| 2 | |
| 3 | Implements ``merge_notes`` (single-note three-way merge) and ``merge_vault`` |
| 4 | (manifest-level vault merge) for Knowtation Markdown notes. Both entry |
| 5 | points are deterministic, idempotent, and operate purely on bytes so they |
| 6 | can be exercised without a Muse repository on disk. |
| 7 | |
| 8 | Algorithm overview |
| 9 | ------------------ |
| 10 | ``merge_notes`` walks four ordered dimensions and resolves each in turn: |
| 11 | |
| 12 | 1. **Title** (frontmatter ``title`` field). |
| 13 | 2. **Frontmatter scalar fields** (every non-set, non-``title`` field). |
| 14 | 3. **Frontmatter set fields** (``tags``, ``entity``, ``follows``, |
| 15 | ``summarizes``, ``attachments``) — three-way set union with |
| 16 | consensus-deletion semantics. |
| 17 | 4. **Sections** (Markdown headings). For each section ID present in any |
| 18 | of the three sides the merger classifies the change (added / deleted / |
| 19 | modified) and either takes one side, applies a clean merge, or — for |
| 20 | irreconcilable line-level overlaps — embeds standard Git conflict |
| 21 | markers (``<<<<<<<`` / ``=======`` / ``>>>>>>>``) inside the section |
| 22 | body and records a structured conflict. |
| 23 | |
| 24 | Body-level merging within a section uses a deterministic ``diff3``-style |
| 25 | line merge built on :class:`difflib.SequenceMatcher`. When the three |
| 26 | line lists share a common subsequence, all changes that fall outside the |
| 27 | overlap are picked up cleanly; only true overlaps escalate to conflict |
| 28 | markers. The same algorithm is used regardless of how many sections |
| 29 | were modified, so every input is reduced to either a clean merge or a |
| 30 | small set of marked conflict regions — never a hard failure. |
| 31 | |
| 32 | Operational-Transform fallback |
| 33 | ----------------------------- |
| 34 | The section-ordering layer uses :func:`muse.core.op_transform.merge_op_lists` |
| 35 | to confirm that the per-side section diffs commute (same address is the |
| 36 | key signal). When ``merge_op_lists`` reports a clean op-level merge the |
| 37 | section ordering is taken directly; conflicting op pairs always re-route |
| 38 | through the body-level diff3 path so that the byte-level output is still |
| 39 | deterministic. This dual path satisfies the Phase 2.4 design goal of |
| 40 | "OT where it commutes, marked conflicts where it doesn't" without |
| 41 | forcing the OT engine to be the source of truth for section bodies. |
| 42 | |
| 43 | Strategy override (``attrs``) |
| 44 | ----------------------------- |
| 45 | When ``attrs`` is supplied the merger first asks |
| 46 | :func:`muse.core.attributes.resolve_strategy` for a file-level (``"*"``) |
| 47 | strategy and for each Knowtation dimension (``"frontmatter"`` and |
| 48 | ``"sections"``). When the resolved strategy is one of the |
| 49 | Knowtation-specific names registered in |
| 50 | :data:`muse.plugins.knowtation.strategies.STRATEGY_DISPATCH`, the merger |
| 51 | delegates the entire dimension (or the entire file) to that strategy via |
| 52 | :func:`muse.plugins.knowtation.strategies.apply_strategy`. Generic |
| 53 | strategies (``ours``, ``theirs``, ``base``, ``union``, ``manual``) are |
| 54 | left for the engine layer; this module never silently swallows them. |
| 55 | |
| 56 | Conflict escalation |
| 57 | ------------------- |
| 58 | ``merge_vault`` writes a stub JSON record to |
| 59 | ``<root>/.muse/conflicts/<sha>.json`` for every note whose ``§Summary`` |
| 60 | section produced an irreconcilable body conflict. The fingerprint is |
| 61 | the SHA-256 of the sorted ``"<section>:<ours_hash>:<theirs_hash>:<base_hash>"`` |
| 62 | joins, so the stub is content-addressed and idempotent — re-running |
| 63 | the merge over the same inputs replaces the file with identical bytes. |
| 64 | The Phase 6 hub-issue renderer can pick these up later without |
| 65 | modifying this module. |
| 66 | |
| 67 | Security |
| 68 | -------- |
| 69 | * Per-note input is capped at :data:`_MAX_NOTE_BYTES` (16 MiB); |
| 70 | oversize inputs raise :class:`ValueError` before any parsing. |
| 71 | * All YAML is decoded via :func:`yaml.safe_load` (delegated to |
| 72 | :mod:`~muse.plugins.knowtation.differ`); arbitrary Python object |
| 73 | instantiation is impossible. |
| 74 | * Conflict markers in the **input** content are escaped with a |
| 75 | zero-width-space prefix in the merged output so that a malicious |
| 76 | branch cannot smuggle a fake conflict marker into the merged note. |
| 77 | * NUL bytes, binary content, and invalid UTF-8 are tolerated end-to-end |
| 78 | via the differ's ``errors="replace"`` decode path. |
| 79 | * The merger never executes user-supplied code and never imports |
| 80 | ``yaml.Loader`` or ``yaml.unsafe_load``. |
| 81 | |
| 82 | No new dependencies are introduced. |
| 83 | """ |
| 84 | |
| 85 | from __future__ import annotations |
| 86 | |
| 87 | import hashlib |
| 88 | import json |
| 89 | import logging |
| 90 | import pathlib |
| 91 | from dataclasses import dataclass, field |
| 92 | from difflib import SequenceMatcher |
| 93 | from typing import Any |
| 94 | |
| 95 | from muse.core.attributes import AttributeRule, resolve_strategy |
| 96 | from muse.core.op_transform import merge_op_lists |
| 97 | from muse.domain import ( |
| 98 | DeleteOp, |
| 99 | DomainOp, |
| 100 | InsertOp, |
| 101 | ) |
| 102 | from muse.plugins.knowtation.differ import ( |
| 103 | _FM_PREFIX, |
| 104 | _MAX_NOTE_BYTES, |
| 105 | _SECTION_PREFIX, |
| 106 | _SET_FIELDS, |
| 107 | _TITLE_KEY, |
| 108 | _Section, |
| 109 | _content_id, |
| 110 | _parse_note, |
| 111 | _section_id, |
| 112 | _serialize_note, |
| 113 | _split_sections_typed, |
| 114 | canonicalize, |
| 115 | ) |
| 116 | from muse.plugins.knowtation.strategies import STRATEGY_DISPATCH, apply_strategy |
| 117 | |
| 118 | logger = logging.getLogger(__name__) |
| 119 | |
| 120 | |
| 121 | # ────────────────────────────────────────────────────────────────────────────── |
| 122 | # Constants |
| 123 | # ────────────────────────────────────────────────────────────────────────────── |
| 124 | |
| 125 | #: Standard git-style conflict markers — emitted verbatim into merged content. |
| 126 | _MARK_OURS: str = "<<<<<<< ours" |
| 127 | _MARK_SEP: str = "=======" |
| 128 | _MARK_THEIRS: str = ">>>>>>> theirs" |
| 129 | |
| 130 | #: Token prefix appended to defang an inbound conflict marker that arrived |
| 131 | #: in the *input*. Zero-width space (U+200B) is invisible in editors but |
| 132 | #: prevents downstream tooling from interpreting the line as a real marker. |
| 133 | _MARKER_DEFANG_PREFIX: str = "\u200b" |
| 134 | |
| 135 | #: Relative path where ``merge_vault`` writes per-conflict JSON stubs. |
| 136 | _CONFLICT_DIR: str = ".muse/conflicts" |
| 137 | |
| 138 | #: Section-title aliases that escalate body conflicts to JSON stubs. The |
| 139 | #: leading ``§`` glyph used in the SPEC is stripped before comparison so |
| 140 | #: both ``"Summary"`` and ``"§Summary"`` match. |
| 141 | _SUMMARY_TITLES: frozenset[str] = frozenset({"summary"}) |
| 142 | |
| 143 | #: Domain name used in any synthetic ops the merger constructs internally. |
| 144 | _DOMAIN: str = "knowtation" |
| 145 | |
| 146 | |
| 147 | # ────────────────────────────────────────────────────────────────────────────── |
| 148 | # Conflict descriptor |
| 149 | # ────────────────────────────────────────────────────────────────────────────── |
| 150 | |
| 151 | |
| 152 | @dataclass |
| 153 | class _NoteConflict: |
| 154 | """One unresolved conflict surfaced by :func:`_merge_notes_internal`. |
| 155 | |
| 156 | Attributes: |
| 157 | path: Vault-relative POSIX path of the note (set by caller). |
| 158 | section: Section ID (``"section:<level>:<title>#<occ>"``) for |
| 159 | section-dimension conflicts, or a short label such as |
| 160 | ``"title"`` / ``"frontmatter:<key>"``. |
| 161 | dimension: ``"title"``, ``"frontmatter"``, or ``"sections"``. |
| 162 | description: Human-readable summary suitable for ``muse merge`` output. |
| 163 | ours_hash: SHA-256 hex of the ours-side content (empty string when |
| 164 | the side deleted the section / field). |
| 165 | theirs_hash: Same, for the theirs side. |
| 166 | base_hash: Same, for the merge-base side. |
| 167 | is_summary: True when the section title (case-insensitive, ``§`` |
| 168 | stripped) is ``"summary"``; toggles the JSON-stub |
| 169 | escalation in :func:`merge_vault`. |
| 170 | """ |
| 171 | |
| 172 | path: str |
| 173 | section: str |
| 174 | dimension: str |
| 175 | description: str |
| 176 | ours_hash: str = "" |
| 177 | theirs_hash: str = "" |
| 178 | base_hash: str = "" |
| 179 | is_summary: bool = False |
| 180 | |
| 181 | def to_dict(self) -> dict[str, str]: |
| 182 | """Return a JSON-serialisable dict (for ``merge_vault`` output).""" |
| 183 | return { |
| 184 | "path": self.path, |
| 185 | "section": self.section, |
| 186 | "dimension": self.dimension, |
| 187 | "description": self.description, |
| 188 | "ours_hash": self.ours_hash, |
| 189 | "theirs_hash": self.theirs_hash, |
| 190 | "base_hash": self.base_hash, |
| 191 | } |
| 192 | |
| 193 | |
| 194 | # ────────────────────────────────────────────────────────────────────────────── |
| 195 | # Hash and string helpers |
| 196 | # ────────────────────────────────────────────────────────────────────────────── |
| 197 | |
| 198 | |
| 199 | def _sha256_text(value: str) -> str: |
| 200 | """Return the SHA-256 hex digest of *value* encoded as UTF-8. |
| 201 | |
| 202 | Args: |
| 203 | value: Arbitrary text (``""`` is a valid input). |
| 204 | |
| 205 | Returns: |
| 206 | 64-character lowercase hex digest. |
| 207 | """ |
| 208 | return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest() |
| 209 | |
| 210 | |
| 211 | def _yaml_str(value: Any) -> str: |
| 212 | """Render a YAML scalar to a stable string. |
| 213 | |
| 214 | Mirrors :func:`muse.plugins.knowtation.differ._stringify` so that two |
| 215 | notes with semantically equal scalar values (e.g. ``True`` vs |
| 216 | ``"true"``) produce identical merge results. |
| 217 | |
| 218 | Args: |
| 219 | value: Any YAML-loadable scalar. |
| 220 | |
| 221 | Returns: |
| 222 | Deterministic string representation. ``None`` becomes ``""``. |
| 223 | """ |
| 224 | if value is None: |
| 225 | return "" |
| 226 | if isinstance(value, bool): |
| 227 | return "true" if value else "false" |
| 228 | return str(value) |
| 229 | |
| 230 | |
| 231 | def _to_str_list(value: Any) -> list[str]: |
| 232 | """Coerce a YAML value to a ``list[str]`` for set-field merging. |
| 233 | |
| 234 | Args: |
| 235 | value: ``None``, a list, or any scalar. |
| 236 | |
| 237 | Returns: |
| 238 | Empty list for ``None``; element-wise stringified list otherwise. |
| 239 | """ |
| 240 | if value is None: |
| 241 | return [] |
| 242 | if isinstance(value, list): |
| 243 | return [_yaml_str(x) for x in value] |
| 244 | return [_yaml_str(value)] |
| 245 | |
| 246 | |
| 247 | def _is_summary_section(sec: _Section) -> bool: |
| 248 | """Return ``True`` when *sec*'s title matches a §Summary alias. |
| 249 | |
| 250 | The SPEC uses the ``§`` glyph (U+00A7) prefix for prominent sections; |
| 251 | the merger strips it before case-insensitive comparison so both |
| 252 | ``"Summary"`` and ``"§Summary"`` escalate alike. |
| 253 | |
| 254 | Args: |
| 255 | sec: Parsed section to test. |
| 256 | |
| 257 | Returns: |
| 258 | Boolean — true when the section is the §Summary section. |
| 259 | """ |
| 260 | title = sec.title.strip().lstrip("§").strip() |
| 261 | return title.lower() in _SUMMARY_TITLES |
| 262 | |
| 263 | |
| 264 | def _defang_inbound_markers(text: str) -> str: |
| 265 | """Prefix any pre-existing conflict-marker lines with a zero-width space. |
| 266 | |
| 267 | This prevents a malicious or accidental ``<<<<<<<`` line in the input |
| 268 | from being misread as a real marker by downstream tooling. The prefix |
| 269 | is invisible in editors and idempotent under canonicalisation. |
| 270 | |
| 271 | Args: |
| 272 | text: Raw line content. |
| 273 | |
| 274 | Returns: |
| 275 | Either *text* unchanged or a defanged copy. |
| 276 | """ |
| 277 | stripped = text.lstrip() |
| 278 | if ( |
| 279 | stripped.startswith("<<<<<<<") |
| 280 | or stripped.startswith("=======") |
| 281 | or stripped.startswith(">>>>>>>") |
| 282 | ): |
| 283 | return _MARKER_DEFANG_PREFIX + text |
| 284 | return text |
| 285 | |
| 286 | |
| 287 | # ────────────────────────────────────────────────────────────────────────────── |
| 288 | # Strategy override dispatch |
| 289 | # ────────────────────────────────────────────────────────────────────────────── |
| 290 | |
| 291 | |
| 292 | def _strategy_override( |
| 293 | attrs: list[AttributeRule] | None, |
| 294 | path: str, |
| 295 | dimension: str, |
| 296 | ours: bytes, |
| 297 | theirs: bytes, |
| 298 | base: bytes, |
| 299 | ) -> bytes | None: |
| 300 | """Return overridden bytes when *attrs* dictates a Knowtation strategy. |
| 301 | |
| 302 | Only the three Knowtation-specific strategies registered in |
| 303 | :data:`muse.plugins.knowtation.strategies.STRATEGY_DISPATCH` |
| 304 | (``prefer-newer-date``, ``union-sorted``, ``knowtation-3way``) are |
| 305 | handled here. Generic strategies (``ours``, ``theirs``, etc.) are |
| 306 | left for the engine layer because the merger must remain deterministic |
| 307 | and stateless. |
| 308 | |
| 309 | Args: |
| 310 | attrs: Rule list from :func:`load_attributes`, or ``None``. |
| 311 | path: Vault-relative POSIX path used for matching. |
| 312 | dimension: Knowtation dimension name (e.g. ``"frontmatter"``) or |
| 313 | ``"*"`` for the file-level lookup. |
| 314 | ours: Ours-side raw bytes. |
| 315 | theirs: Theirs-side raw bytes. |
| 316 | base: Merge-base raw bytes. |
| 317 | |
| 318 | Returns: |
| 319 | Resolved bytes when the strategy is Knowtation-specific, else ``None``. |
| 320 | """ |
| 321 | if not attrs: |
| 322 | return None |
| 323 | strategy = resolve_strategy(attrs, path, dimension) |
| 324 | if strategy in STRATEGY_DISPATCH and strategy != "knowtation-3way": |
| 325 | # Don't recurse into our own strategy; that would deadlock when |
| 326 | # knowtation-3way is the file-level default. |
| 327 | return apply_strategy(strategy, ours, theirs, base) |
| 328 | return None |
| 329 | |
| 330 | |
| 331 | # ────────────────────────────────────────────────────────────────────────────── |
| 332 | # Title merge (Dimension 1) |
| 333 | # ────────────────────────────────────────────────────────────────────────────── |
| 334 | |
| 335 | |
| 336 | def _merge_title( |
| 337 | o: Any, t: Any, b: Any |
| 338 | ) -> tuple[Any, _NoteConflict | None]: |
| 339 | """Three-way merge of the frontmatter ``title`` field. |
| 340 | |
| 341 | Args: |
| 342 | o: Title on the ours side (``None`` when absent). |
| 343 | t: Title on the theirs side. |
| 344 | b: Title on the base side. |
| 345 | |
| 346 | Returns: |
| 347 | Tuple ``(resolved, conflict)``. *resolved* is the value to write |
| 348 | to the merged frontmatter (or ``None`` to omit the key). |
| 349 | *conflict* is ``None`` for clean merges and a :class:`_NoteConflict` |
| 350 | when both sides changed to different non-``None`` values. |
| 351 | """ |
| 352 | if o == t: |
| 353 | return o, None |
| 354 | if o == b: |
| 355 | return t, None |
| 356 | if t == b: |
| 357 | return o, None |
| 358 | |
| 359 | o_str = _yaml_str(o) |
| 360 | t_str = _yaml_str(t) |
| 361 | marker = ( |
| 362 | f"{_MARK_OURS}\n{o_str}\n{_MARK_SEP}\n{t_str}\n{_MARK_THEIRS}" |
| 363 | ) |
| 364 | conflict = _NoteConflict( |
| 365 | path="", |
| 366 | section="title", |
| 367 | dimension="title", |
| 368 | description="title differs in both branches", |
| 369 | ours_hash=_sha256_text(o_str), |
| 370 | theirs_hash=_sha256_text(t_str), |
| 371 | base_hash=_sha256_text(_yaml_str(b)), |
| 372 | ) |
| 373 | return marker, conflict |
| 374 | |
| 375 | |
| 376 | # ────────────────────────────────────────────────────────────────────────────── |
| 377 | # Frontmatter scalar merge (Dimension 2) |
| 378 | # ────────────────────────────────────────────────────────────────────────────── |
| 379 | |
| 380 | |
| 381 | def _merge_scalar_field( |
| 382 | o: Any, t: Any, b: Any |
| 383 | ) -> tuple[Any, bool, bool]: |
| 384 | """Three-way merge of a single non-set scalar frontmatter field. |
| 385 | |
| 386 | Args: |
| 387 | o: Ours-side value (``None`` when the key is absent). |
| 388 | t: Theirs-side value. |
| 389 | b: Base-side value. |
| 390 | |
| 391 | Returns: |
| 392 | Tuple ``(resolved, has_conflict, present)``. |
| 393 | |
| 394 | - ``resolved`` — the merged value (only meaningful when |
| 395 | ``present`` is ``True``). |
| 396 | - ``has_conflict`` — ``True`` when both sides changed to different |
| 397 | non-``None`` values; per spec, the ours side wins. |
| 398 | - ``present`` — ``False`` when the key should be **omitted** |
| 399 | from the merged frontmatter (consensus-delete or never-existed). |
| 400 | """ |
| 401 | if o == t: |
| 402 | return o, False, o is not None |
| 403 | if o == b: |
| 404 | return t, False, t is not None |
| 405 | if t == b: |
| 406 | return o, False, o is not None |
| 407 | logger.warning( |
| 408 | "Frontmatter scalar conflict (ours=%r, theirs=%r, base=%r) — ours wins", |
| 409 | o, |
| 410 | t, |
| 411 | b, |
| 412 | ) |
| 413 | return o, True, o is not None |
| 414 | |
| 415 | |
| 416 | # ────────────────────────────────────────────────────────────────────────────── |
| 417 | # Frontmatter set merge (Dimension 3) |
| 418 | # ────────────────────────────────────────────────────────────────────────────── |
| 419 | |
| 420 | |
| 421 | def _merge_set_field(o: Any, t: Any, b: Any) -> list[str]: |
| 422 | """Three-way set merge for ``tags`` / ``entity`` / etc. |
| 423 | |
| 424 | Algorithm: |
| 425 | |
| 426 | * Element kept by either branch and not deleted by **both** survives. |
| 427 | * Element deleted on both sides is removed. |
| 428 | * Element added by one side (regardless of whether the other side also |
| 429 | added it) is included exactly once. |
| 430 | |
| 431 | The output is sorted alphabetically so that canonicalisation is stable |
| 432 | and merge results are byte-identical regardless of input ordering. |
| 433 | |
| 434 | Args: |
| 435 | o: Ours-side list (or scalar / ``None``). |
| 436 | t: Theirs-side list. |
| 437 | b: Base-side list. |
| 438 | |
| 439 | Returns: |
| 440 | Sorted, deduplicated ``list[str]`` of survivors. |
| 441 | """ |
| 442 | o_set = set(_to_str_list(o)) |
| 443 | t_set = set(_to_str_list(t)) |
| 444 | b_set = set(_to_str_list(b)) |
| 445 | deleted_by_both = (b_set - o_set) & (b_set - t_set) |
| 446 | added_by_either = (o_set | t_set) - b_set |
| 447 | survivors = (b_set - deleted_by_both) | added_by_either |
| 448 | return sorted(survivors) |
| 449 | |
| 450 | |
| 451 | # ────────────────────────────────────────────────────────────────────────────── |
| 452 | # Frontmatter merge (combines Dimensions 1–3) |
| 453 | # ────────────────────────────────────────────────────────────────────────────── |
| 454 | |
| 455 | |
| 456 | def _merge_frontmatter( |
| 457 | o_fm: dict[str, Any], |
| 458 | t_fm: dict[str, Any], |
| 459 | b_fm: dict[str, Any], |
| 460 | ) -> tuple[dict[str, Any], list[_NoteConflict]]: |
| 461 | """Per-key three-way merge across the entire frontmatter mapping. |
| 462 | |
| 463 | Args: |
| 464 | o_fm: Ours-side frontmatter dict. |
| 465 | t_fm: Theirs-side frontmatter dict. |
| 466 | b_fm: Base-side frontmatter dict. |
| 467 | |
| 468 | Returns: |
| 469 | Tuple ``(merged_fm, conflicts)``. *merged_fm* is the new |
| 470 | frontmatter dict in canonical key order (title first, others |
| 471 | alphabetical). *conflicts* is the list of :class:`_NoteConflict` |
| 472 | entries for irreconcilable scalar / title disagreements. |
| 473 | """ |
| 474 | merged: dict[str, Any] = {} |
| 475 | conflicts: list[_NoteConflict] = [] |
| 476 | |
| 477 | title_resolved, title_conflict = _merge_title( |
| 478 | o_fm.get(_TITLE_KEY), t_fm.get(_TITLE_KEY), b_fm.get(_TITLE_KEY) |
| 479 | ) |
| 480 | if title_resolved is not None and title_resolved != "": |
| 481 | merged[_TITLE_KEY] = title_resolved |
| 482 | if title_conflict is not None: |
| 483 | conflicts.append(title_conflict) |
| 484 | |
| 485 | all_keys = (set(o_fm) | set(t_fm) | set(b_fm)) - {_TITLE_KEY} |
| 486 | for key in sorted(all_keys): |
| 487 | o_val = o_fm.get(key) |
| 488 | t_val = t_fm.get(key) |
| 489 | b_val = b_fm.get(key) |
| 490 | |
| 491 | if key in _SET_FIELDS: |
| 492 | survivors = _merge_set_field(o_val, t_val, b_val) |
| 493 | if survivors: |
| 494 | merged[key] = survivors |
| 495 | continue |
| 496 | |
| 497 | resolved, has_conflict, present = _merge_scalar_field(o_val, t_val, b_val) |
| 498 | if present: |
| 499 | merged[key] = resolved |
| 500 | if has_conflict: |
| 501 | conflicts.append( |
| 502 | _NoteConflict( |
| 503 | path="", |
| 504 | section=f"frontmatter:{key}", |
| 505 | dimension="frontmatter", |
| 506 | description=f"scalar field {key!r} differs in both branches", |
| 507 | ours_hash=_sha256_text(_yaml_str(o_val)), |
| 508 | theirs_hash=_sha256_text(_yaml_str(t_val)), |
| 509 | base_hash=_sha256_text(_yaml_str(b_val)), |
| 510 | ) |
| 511 | ) |
| 512 | |
| 513 | return merged, conflicts |
| 514 | |
| 515 | |
| 516 | # ────────────────────────────────────────────────────────────────────────────── |
| 517 | # Line-level diff3 merge (used by the section-body merger) |
| 518 | # ────────────────────────────────────────────────────────────────────────────── |
| 519 | |
| 520 | |
| 521 | def _three_way_merge_lines( |
| 522 | base: list[str], ours: list[str], theirs: list[str] |
| 523 | ) -> tuple[list[str], int]: |
| 524 | """Diff3-style three-way merge of three line lists. |
| 525 | |
| 526 | The algorithm finds matching subsequences (LCS) of base↔ours and |
| 527 | base↔theirs via :class:`difflib.SequenceMatcher`, intersects them to |
| 528 | obtain a set of shared *anchor* indices in the base, then walks the |
| 529 | anchors emitting one chunk at a time: |
| 530 | |
| 531 | * Empty unstable region between anchors → no output. |
| 532 | * Both sides made the same change → take it once. |
| 533 | * Only ours changed → take ours. |
| 534 | * Only theirs changed → take theirs. |
| 535 | * Both sides made different changes → embed conflict markers. |
| 536 | |
| 537 | The anchor lines themselves are guaranteed equal in both branches and |
| 538 | are appended verbatim. All lines from the input are passed through |
| 539 | :func:`_defang_inbound_markers` so a malicious branch cannot smuggle |
| 540 | a fake marker into the merged output. |
| 541 | |
| 542 | Args: |
| 543 | base: Base-side line list. |
| 544 | ours: Ours-side line list. |
| 545 | theirs: Theirs-side line list. |
| 546 | |
| 547 | Returns: |
| 548 | Tuple ``(merged_lines, conflict_count)`` — *merged_lines* is the |
| 549 | full output line sequence; *conflict_count* is the number of |
| 550 | marker triples emitted (0 on a clean merge). |
| 551 | """ |
| 552 | base = [_defang_inbound_markers(line) for line in base] |
| 553 | ours = [_defang_inbound_markers(line) for line in ours] |
| 554 | theirs = [_defang_inbound_markers(line) for line in theirs] |
| 555 | |
| 556 | if ours == theirs: |
| 557 | return list(ours), 0 |
| 558 | if ours == base: |
| 559 | return list(theirs), 0 |
| 560 | if theirs == base: |
| 561 | return list(ours), 0 |
| 562 | |
| 563 | o_blocks = list( |
| 564 | SequenceMatcher(a=base, b=ours, autojunk=False).get_matching_blocks() |
| 565 | ) |
| 566 | t_blocks = list( |
| 567 | SequenceMatcher(a=base, b=theirs, autojunk=False).get_matching_blocks() |
| 568 | ) |
| 569 | |
| 570 | o_pos: dict[int, int] = {} |
| 571 | for blk in o_blocks: |
| 572 | for i in range(blk.size): |
| 573 | o_pos[blk.a + i] = blk.b + i |
| 574 | t_pos: dict[int, int] = {} |
| 575 | for blk in t_blocks: |
| 576 | for i in range(blk.size): |
| 577 | t_pos[blk.a + i] = blk.b + i |
| 578 | |
| 579 | common = sorted(set(o_pos) & set(t_pos)) |
| 580 | common.append(len(base)) # sentinel — flushes the tail chunk |
| 581 | |
| 582 | result: list[str] = [] |
| 583 | conflicts = 0 |
| 584 | bi_prev = -1 |
| 585 | oi_prev = -1 |
| 586 | ti_prev = -1 |
| 587 | |
| 588 | for bi in common: |
| 589 | if bi == len(base): |
| 590 | oi = len(ours) |
| 591 | ti = len(theirs) |
| 592 | else: |
| 593 | oi = o_pos[bi] |
| 594 | ti = t_pos[bi] |
| 595 | |
| 596 | base_chunk = base[bi_prev + 1 : bi] |
| 597 | ours_chunk = ours[oi_prev + 1 : oi] |
| 598 | theirs_chunk = theirs[ti_prev + 1 : ti] |
| 599 | |
| 600 | if not base_chunk and not ours_chunk and not theirs_chunk: |
| 601 | pass |
| 602 | elif ours_chunk == theirs_chunk: |
| 603 | result.extend(ours_chunk) |
| 604 | elif base_chunk == ours_chunk: |
| 605 | result.extend(theirs_chunk) |
| 606 | elif base_chunk == theirs_chunk: |
| 607 | result.extend(ours_chunk) |
| 608 | else: |
| 609 | result.append(_MARK_OURS) |
| 610 | result.extend(ours_chunk) |
| 611 | result.append(_MARK_SEP) |
| 612 | result.extend(theirs_chunk) |
| 613 | result.append(_MARK_THEIRS) |
| 614 | conflicts += 1 |
| 615 | |
| 616 | if bi < len(base): |
| 617 | result.append(base[bi]) |
| 618 | bi_prev = bi |
| 619 | oi_prev = oi |
| 620 | ti_prev = ti |
| 621 | |
| 622 | return result, conflicts |
| 623 | |
| 624 | |
| 625 | def _merge_section_body( |
| 626 | b_text: str, o_text: str, t_text: str |
| 627 | ) -> tuple[str, int]: |
| 628 | """Merge one section's body text three ways at line granularity. |
| 629 | |
| 630 | Splitting uses ``splitlines(keepends=True)`` so trailing newlines are |
| 631 | preserved verbatim. Conflict markers are joined with explicit ``\\n`` |
| 632 | terminators because they are synthetic and have no inherent newline. |
| 633 | |
| 634 | Args: |
| 635 | b_text: Base-side section text. |
| 636 | o_text: Ours-side section text. |
| 637 | t_text: Theirs-side section text. |
| 638 | |
| 639 | Returns: |
| 640 | Tuple ``(merged_text, conflict_count)``. |
| 641 | """ |
| 642 | if o_text == t_text: |
| 643 | return o_text, 0 |
| 644 | if o_text == b_text: |
| 645 | return t_text, 0 |
| 646 | if t_text == b_text: |
| 647 | return o_text, 0 |
| 648 | |
| 649 | base_lines = b_text.splitlines(keepends=True) |
| 650 | ours_lines = o_text.splitlines(keepends=True) |
| 651 | theirs_lines = t_text.splitlines(keepends=True) |
| 652 | |
| 653 | merged, n_conflicts = _three_way_merge_lines(base_lines, ours_lines, theirs_lines) |
| 654 | |
| 655 | pieces: list[str] = [] |
| 656 | for line in merged: |
| 657 | if line in (_MARK_OURS, _MARK_SEP, _MARK_THEIRS): |
| 658 | pieces.append(line + "\n") |
| 659 | else: |
| 660 | pieces.append(line) |
| 661 | return "".join(pieces), n_conflicts |
| 662 | |
| 663 | |
| 664 | # ────────────────────────────────────────────────────────────────────────────── |
| 665 | # Section structural merge (Dimension 4) |
| 666 | # ────────────────────────────────────────────────────────────────────────────── |
| 667 | |
| 668 | |
| 669 | def _build_section_ops( |
| 670 | base_secs: list[_Section], target_secs: list[_Section] |
| 671 | ) -> list[DomainOp]: |
| 672 | """Construct synthetic Insert / Delete ops for the section ID sequence. |
| 673 | |
| 674 | The ops are intended for :func:`muse.core.op_transform.merge_op_lists` |
| 675 | — they describe *which* IDs are added or removed without carrying |
| 676 | body content (which is merged separately). |
| 677 | |
| 678 | Args: |
| 679 | base_secs: Base-side section list. |
| 680 | target_secs: Target-side section list (ours or theirs). |
| 681 | |
| 682 | Returns: |
| 683 | List of :class:`InsertOp` / :class:`DeleteOp` describing the |
| 684 | symmetric difference between the two ID sets. |
| 685 | """ |
| 686 | base_ids = [s.sid for s in base_secs] |
| 687 | target_ids = [s.sid for s in target_secs] |
| 688 | base_set = set(base_ids) |
| 689 | target_set = set(target_ids) |
| 690 | |
| 691 | ops: list[DomainOp] = [] |
| 692 | target_index = {sid: i for i, sid in enumerate(target_ids)} |
| 693 | base_index = {sid: i for i, sid in enumerate(base_ids)} |
| 694 | |
| 695 | for sid in target_set - base_set: |
| 696 | ops.append( |
| 697 | InsertOp( |
| 698 | op="insert", |
| 699 | address=sid, |
| 700 | position=target_index[sid], |
| 701 | content_id=_content_id(sid), |
| 702 | content_summary=sid, |
| 703 | ) |
| 704 | ) |
| 705 | for sid in base_set - target_set: |
| 706 | ops.append( |
| 707 | DeleteOp( |
| 708 | op="delete", |
| 709 | address=sid, |
| 710 | position=base_index[sid], |
| 711 | content_id=_content_id(sid), |
| 712 | content_summary=sid, |
| 713 | ) |
| 714 | ) |
| 715 | return ops |
| 716 | |
| 717 | |
| 718 | def _merge_sections( |
| 719 | b_secs: list[_Section], |
| 720 | o_secs: list[_Section], |
| 721 | t_secs: list[_Section], |
| 722 | ) -> tuple[list[_Section], list[_NoteConflict]]: |
| 723 | """Three-way merge of two section lists against a common base list. |
| 724 | |
| 725 | Inclusion is computed per section ID: |
| 726 | |
| 727 | * In neither ours nor theirs → exclude. |
| 728 | * In only one side, never in base → include that side's content. |
| 729 | * In one side and base, but not the other → if unchanged in the |
| 730 | keeping side, treat as consensus delete; else keep the modified side |
| 731 | and record a delete-edit conflict. |
| 732 | * In both sides (and possibly base) → merge bodies via |
| 733 | :func:`_merge_section_body`. |
| 734 | |
| 735 | Section ordering is determined by ours-side order with theirs-side |
| 736 | additions appended in their relative theirs order. This matches user |
| 737 | expectations because ``ours`` represents the local working state. |
| 738 | |
| 739 | The OT engine (:func:`muse.core.op_transform.merge_op_lists`) is |
| 740 | consulted purely as a sanity oracle: when its op-level merge reports |
| 741 | no conflicts the structural merge is by definition clean and we can |
| 742 | short-circuit straight to body-level resolution. When it reports |
| 743 | conflicts the same body-level path runs (no behaviour change), but |
| 744 | the OT result is logged for diagnostics. |
| 745 | |
| 746 | Args: |
| 747 | b_secs: Base-side section list. |
| 748 | o_secs: Ours-side section list. |
| 749 | t_secs: Theirs-side section list. |
| 750 | |
| 751 | Returns: |
| 752 | Tuple ``(merged_sections, conflicts)``. |
| 753 | """ |
| 754 | o_ops = _build_section_ops(b_secs, o_secs) |
| 755 | t_ops = _build_section_ops(b_secs, t_secs) |
| 756 | ot_result = merge_op_lists([], o_ops, t_ops) |
| 757 | if ot_result.conflict_ops: |
| 758 | logger.debug( |
| 759 | "Section ID OT merge reported %d conflicting op pairs; " |
| 760 | "falling through to body-level diff3.", |
| 761 | len(ot_result.conflict_ops), |
| 762 | ) |
| 763 | |
| 764 | b_by_id = {s.sid: s for s in b_secs} |
| 765 | o_by_id = {s.sid: s for s in o_secs} |
| 766 | t_by_id = {s.sid: s for s in t_secs} |
| 767 | all_ids = set(b_by_id) | set(o_by_id) | set(t_by_id) |
| 768 | |
| 769 | resolved: dict[str, _Section | None] = {} |
| 770 | conflicts: list[_NoteConflict] = [] |
| 771 | |
| 772 | for sid in all_ids: |
| 773 | b_sec = b_by_id.get(sid) |
| 774 | o_sec = o_by_id.get(sid) |
| 775 | t_sec = t_by_id.get(sid) |
| 776 | in_b, in_o, in_t = b_sec is not None, o_sec is not None, t_sec is not None |
| 777 | |
| 778 | if not in_o and not in_t: |
| 779 | resolved[sid] = None |
| 780 | continue |
| 781 | |
| 782 | if in_o and not in_t: |
| 783 | assert o_sec is not None |
| 784 | if not in_b: |
| 785 | resolved[sid] = o_sec |
| 786 | else: |
| 787 | assert b_sec is not None |
| 788 | if o_sec.text == b_sec.text: |
| 789 | resolved[sid] = None |
| 790 | else: |
| 791 | resolved[sid] = o_sec |
| 792 | conflicts.append( |
| 793 | _NoteConflict( |
| 794 | path="", |
| 795 | section=sid, |
| 796 | dimension="sections", |
| 797 | description=( |
| 798 | f"section {sid!r} deleted by theirs but " |
| 799 | f"modified by ours" |
| 800 | ), |
| 801 | ours_hash=_sha256_text(o_sec.text), |
| 802 | theirs_hash="", |
| 803 | base_hash=_sha256_text(b_sec.text), |
| 804 | is_summary=_is_summary_section(o_sec), |
| 805 | ) |
| 806 | ) |
| 807 | continue |
| 808 | |
| 809 | if in_t and not in_o: |
| 810 | assert t_sec is not None |
| 811 | if not in_b: |
| 812 | resolved[sid] = t_sec |
| 813 | else: |
| 814 | assert b_sec is not None |
| 815 | if t_sec.text == b_sec.text: |
| 816 | resolved[sid] = None |
| 817 | else: |
| 818 | resolved[sid] = t_sec |
| 819 | conflicts.append( |
| 820 | _NoteConflict( |
| 821 | path="", |
| 822 | section=sid, |
| 823 | dimension="sections", |
| 824 | description=( |
| 825 | f"section {sid!r} deleted by ours but " |
| 826 | f"modified by theirs" |
| 827 | ), |
| 828 | ours_hash="", |
| 829 | theirs_hash=_sha256_text(t_sec.text), |
| 830 | base_hash=_sha256_text(b_sec.text), |
| 831 | is_summary=_is_summary_section(t_sec), |
| 832 | ) |
| 833 | ) |
| 834 | continue |
| 835 | |
| 836 | # Present in both ours and theirs (and possibly base) |
| 837 | assert o_sec is not None and t_sec is not None |
| 838 | b_text = b_sec.text if in_b else "" |
| 839 | merged_text, n = _merge_section_body(b_text, o_sec.text, t_sec.text) |
| 840 | merged_sec = _Section( |
| 841 | sid=sid, |
| 842 | level=o_sec.level, |
| 843 | title=o_sec.title, |
| 844 | text=merged_text, |
| 845 | ) |
| 846 | resolved[sid] = merged_sec |
| 847 | if n > 0: |
| 848 | conflicts.append( |
| 849 | _NoteConflict( |
| 850 | path="", |
| 851 | section=sid, |
| 852 | dimension="sections", |
| 853 | description=( |
| 854 | f"section {sid!r} body conflict ({n} unresolved chunk" |
| 855 | f"{'s' if n != 1 else ''})" |
| 856 | ), |
| 857 | ours_hash=_sha256_text(o_sec.text), |
| 858 | theirs_hash=_sha256_text(t_sec.text), |
| 859 | base_hash=_sha256_text(b_text), |
| 860 | is_summary=_is_summary_section(o_sec), |
| 861 | ) |
| 862 | ) |
| 863 | |
| 864 | ordered: list[_Section] = [] |
| 865 | seen: set[str] = set() |
| 866 | for sec in o_secs: |
| 867 | if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: |
| 868 | r = resolved[sec.sid] |
| 869 | assert r is not None |
| 870 | ordered.append(r) |
| 871 | seen.add(sec.sid) |
| 872 | for sec in t_secs: |
| 873 | if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: |
| 874 | r = resolved[sec.sid] |
| 875 | assert r is not None |
| 876 | ordered.append(r) |
| 877 | seen.add(sec.sid) |
| 878 | for sec in b_secs: |
| 879 | if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: |
| 880 | r = resolved[sec.sid] |
| 881 | assert r is not None |
| 882 | ordered.append(r) |
| 883 | seen.add(sec.sid) |
| 884 | |
| 885 | return ordered, conflicts |
| 886 | |
| 887 | |
| 888 | # ────────────────────────────────────────────────────────────────────────────── |
| 889 | # merge_notes — public entry point |
| 890 | # ────────────────────────────────────────────────────────────────────────────── |
| 891 | |
| 892 | |
| 893 | def _merge_notes_internal( |
| 894 | ours: bytes, |
| 895 | theirs: bytes, |
| 896 | base: bytes, |
| 897 | *, |
| 898 | path: str = "", |
| 899 | attrs: list[AttributeRule] | None = None, |
| 900 | ) -> tuple[bytes, list[_NoteConflict]]: |
| 901 | """Three-way merge two notes; return ``(merged_bytes, conflicts)``. |
| 902 | |
| 903 | Internal counterpart to :func:`merge_notes` — exposed so that |
| 904 | :func:`merge_vault` can collect structured conflicts before writing |
| 905 | JSON stubs to ``.muse/conflicts/``. |
| 906 | |
| 907 | Args: |
| 908 | ours: Ours-side raw bytes. |
| 909 | theirs: Theirs-side raw bytes. |
| 910 | base: Merge-base raw bytes. |
| 911 | path: Vault-relative POSIX path (used for ``attrs`` lookup |
| 912 | and stamped onto every emitted conflict). |
| 913 | attrs: Optional ``.museattributes`` rule list. |
| 914 | |
| 915 | Returns: |
| 916 | Tuple ``(merged_bytes, conflicts)``. |
| 917 | |
| 918 | Raises: |
| 919 | ValueError: If any input exceeds :data:`_MAX_NOTE_BYTES`. |
| 920 | """ |
| 921 | for name, blob in (("ours", ours), ("theirs", theirs), ("base", base)): |
| 922 | if len(blob) > _MAX_NOTE_BYTES: |
| 923 | raise ValueError( |
| 924 | f"Note {name!r} size {len(blob)} bytes exceeds maximum " |
| 925 | f"{_MAX_NOTE_BYTES}" |
| 926 | ) |
| 927 | |
| 928 | # NOTE: only the strict ``ours == theirs`` early-out is safe. The |
| 929 | # ``ours == base`` and ``theirs == base`` shortcuts are intentionally |
| 930 | # **omitted** because they would bypass the union-sorted semantics of |
| 931 | # set-valued frontmatter fields (Dimension 3). Per spec, a tag deleted |
| 932 | # by only one side must be kept when the other side still wants it, |
| 933 | # which requires running the per-dimension merge even when one side is |
| 934 | # bytewise unchanged. |
| 935 | if ours == theirs: |
| 936 | return canonicalize(ours), [] |
| 937 | |
| 938 | file_override = _strategy_override(attrs, path, "*", ours, theirs, base) |
| 939 | if file_override is not None: |
| 940 | return canonicalize(file_override), [] |
| 941 | |
| 942 | o_parsed = _parse_note(ours) |
| 943 | t_parsed = _parse_note(theirs) |
| 944 | b_parsed = _parse_note(base) |
| 945 | |
| 946 | fm_override = _strategy_override(attrs, path, "frontmatter", ours, theirs, base) |
| 947 | if fm_override is not None: |
| 948 | merged_fm = _parse_note(fm_override).frontmatter |
| 949 | fm_conflicts: list[_NoteConflict] = [] |
| 950 | else: |
| 951 | merged_fm, fm_conflicts = _merge_frontmatter( |
| 952 | o_parsed.frontmatter, t_parsed.frontmatter, b_parsed.frontmatter |
| 953 | ) |
| 954 | |
| 955 | sec_override = _strategy_override(attrs, path, "sections", ours, theirs, base) |
| 956 | if sec_override is not None: |
| 957 | merged_sections = _split_sections_typed(_parse_note(sec_override).body) |
| 958 | section_conflicts: list[_NoteConflict] = [] |
| 959 | else: |
| 960 | merged_sections, section_conflicts = _merge_sections( |
| 961 | _split_sections_typed(b_parsed.body), |
| 962 | _split_sections_typed(o_parsed.body), |
| 963 | _split_sections_typed(t_parsed.body), |
| 964 | ) |
| 965 | |
| 966 | new_body = "".join(s.text for s in merged_sections) |
| 967 | out = _serialize_note(merged_fm, new_body) |
| 968 | |
| 969 | all_conflicts = fm_conflicts + section_conflicts |
| 970 | for c in all_conflicts: |
| 971 | c.path = path |
| 972 | |
| 973 | return out, all_conflicts |
| 974 | |
| 975 | |
| 976 | def merge_notes( |
| 977 | ours: bytes, |
| 978 | theirs: bytes, |
| 979 | base: bytes, |
| 980 | *, |
| 981 | path: str = "", |
| 982 | attrs: list[AttributeRule] | None = None, |
| 983 | ) -> bytes: |
| 984 | """Three-way merge of two note versions against a common base. |
| 985 | |
| 986 | On a clean merge the result is canonical-form bytes (LF newlines, |
| 987 | sorted set fields, alphabetised frontmatter keys). On an |
| 988 | irreconcilable conflict the merged content embeds standard git |
| 989 | conflict markers (``<<<<<<<`` / ``=======`` / ``>>>>>>>``) within the |
| 990 | affected dimension; the bytes are still safe to write to disk and to |
| 991 | re-merge later. |
| 992 | |
| 993 | Calling ``merge_notes`` twice with identical inputs produces |
| 994 | byte-identical outputs (idempotent). Inputs that exceed the 16 MiB |
| 995 | per-note cap raise :class:`ValueError`. |
| 996 | |
| 997 | Args: |
| 998 | ours: Ours-side raw bytes. |
| 999 | theirs: Theirs-side raw bytes. |
| 1000 | base: Merge-base raw bytes. |
| 1001 | path: Optional vault-relative POSIX path (used for |
| 1002 | ``.museattributes`` lookup). |
| 1003 | attrs: Optional rule list from |
| 1004 | :func:`muse.core.attributes.load_attributes`. |
| 1005 | |
| 1006 | Returns: |
| 1007 | Merged note bytes in canonical form (with conflict markers when |
| 1008 | applicable). |
| 1009 | |
| 1010 | Raises: |
| 1011 | ValueError: If any input exceeds the 16 MiB note cap. |
| 1012 | """ |
| 1013 | merged_bytes, _ = _merge_notes_internal( |
| 1014 | ours, theirs, base, path=path, attrs=attrs |
| 1015 | ) |
| 1016 | return merged_bytes |
| 1017 | |
| 1018 | |
| 1019 | # ────────────────────────────────────────────────────────────────────────────── |
| 1020 | # Conflict stub writer |
| 1021 | # ────────────────────────────────────────────────────────────────────────────── |
| 1022 | |
| 1023 | |
| 1024 | def _conflict_fingerprint(conflicts: list[_NoteConflict]) -> str: |
| 1025 | """Compute a deterministic SHA-256 fingerprint over a conflict list. |
| 1026 | |
| 1027 | The fingerprint is the SHA-256 of the sorted |
| 1028 | ``"<section>:<ours_hash>:<theirs_hash>:<base_hash>"`` joins. Sorting |
| 1029 | guarantees that the same set of conflicts always produces the same |
| 1030 | fingerprint regardless of ordering. |
| 1031 | |
| 1032 | Args: |
| 1033 | conflicts: List of :class:`_NoteConflict` to fingerprint. |
| 1034 | |
| 1035 | Returns: |
| 1036 | 64-character lowercase hex fingerprint. Empty input returns the |
| 1037 | well-known empty-string SHA-256. |
| 1038 | """ |
| 1039 | pairs = sorted( |
| 1040 | f"{c.section}:{c.ours_hash}:{c.theirs_hash}:{c.base_hash}" |
| 1041 | for c in conflicts |
| 1042 | ) |
| 1043 | payload = "|".join(pairs) |
| 1044 | return hashlib.sha256(payload.encode("utf-8")).hexdigest() |
| 1045 | |
| 1046 | |
| 1047 | def _write_conflict_stub( |
| 1048 | root: pathlib.Path, |
| 1049 | conflicts: list[_NoteConflict], |
| 1050 | ) -> str | None: |
| 1051 | """Write a JSON stub for §Summary conflicts; return its fingerprint. |
| 1052 | |
| 1053 | The stub lives at ``<root>/.muse/conflicts/<fingerprint>.json`` and |
| 1054 | is content-addressed: re-running the merge with identical inputs |
| 1055 | overwrites the file with identical bytes. When no §Summary conflict |
| 1056 | is present the function is a no-op and returns ``None``. |
| 1057 | |
| 1058 | Args: |
| 1059 | root: Repository root (parent of ``.muse/``). |
| 1060 | conflicts: List of :class:`_NoteConflict` already stamped with |
| 1061 | ``path`` by :func:`_merge_notes_internal`. |
| 1062 | |
| 1063 | Returns: |
| 1064 | The stub fingerprint when a file was written, otherwise ``None``. |
| 1065 | """ |
| 1066 | summary_conflicts = [c for c in conflicts if c.is_summary] |
| 1067 | if not summary_conflicts: |
| 1068 | return None |
| 1069 | fp = _conflict_fingerprint(summary_conflicts) |
| 1070 | target_dir = root / _CONFLICT_DIR |
| 1071 | target_dir.mkdir(parents=True, exist_ok=True) |
| 1072 | stub_path = target_dir / f"{fp}.json" |
| 1073 | head = summary_conflicts[0] |
| 1074 | payload = { |
| 1075 | "path": head.path, |
| 1076 | "section": head.section, |
| 1077 | "ours_hash": head.ours_hash, |
| 1078 | "theirs_hash": head.theirs_hash, |
| 1079 | "base_hash": head.base_hash, |
| 1080 | "fingerprint": fp, |
| 1081 | } |
| 1082 | stub_path.write_text( |
| 1083 | json.dumps(payload, sort_keys=True, indent=2), |
| 1084 | encoding="utf-8", |
| 1085 | ) |
| 1086 | return fp |
| 1087 | |
| 1088 | |
| 1089 | # ────────────────────────────────────────────────────────────────────────────── |
| 1090 | # merge_vault — public entry point |
| 1091 | # ────────────────────────────────────────────────────────────────────────────── |
| 1092 | |
| 1093 | |
| 1094 | def _read_blob(root: pathlib.Path, content_hash: str | None) -> bytes: |
| 1095 | """Read a content-addressed blob, falling back gracefully. |
| 1096 | |
| 1097 | Tries the Muse object store first (``<root>/.muse/objects/...``) via |
| 1098 | :func:`muse.core.object_store.read_object`. When that store is not |
| 1099 | available or the blob is missing, falls back to a flat |
| 1100 | ``<root>/.knowtation_blobs/<hash>`` directory which the test suite |
| 1101 | populates directly — this keeps ``merge_vault`` testable without a |
| 1102 | fully-initialised Muse repository. |
| 1103 | |
| 1104 | Args: |
| 1105 | root: Repository root. |
| 1106 | content_hash: SHA-256 hex digest, or ``None``. |
| 1107 | |
| 1108 | Returns: |
| 1109 | Raw bytes (possibly empty) for the blob. Returns ``b""`` when |
| 1110 | *content_hash* is ``None`` so callers can treat "absent" as |
| 1111 | "deleted" without checking for ``None``. |
| 1112 | """ |
| 1113 | if content_hash is None: |
| 1114 | return b"" |
| 1115 | try: |
| 1116 | from muse.core.object_store import read_object # local import — optional |
| 1117 | |
| 1118 | blob = read_object(root, content_hash) |
| 1119 | if blob is not None: |
| 1120 | return blob |
| 1121 | except (ImportError, ValueError, OSError): |
| 1122 | pass |
| 1123 | |
| 1124 | fallback = root / ".knowtation_blobs" / content_hash |
| 1125 | if fallback.exists(): |
| 1126 | return fallback.read_bytes() |
| 1127 | return b"" |
| 1128 | |
| 1129 | |
| 1130 | def _files_of(manifest: dict[str, Any]) -> dict[str, str]: |
| 1131 | """Extract the ``"files"`` mapping from a snapshot manifest. |
| 1132 | |
| 1133 | Accepts either a full :class:`~muse.domain.SnapshotManifest` |
| 1134 | (``{"files": {...}, "domain": "...", ...}``) or a bare path → hash |
| 1135 | dict, returning the latter form unchanged. |
| 1136 | |
| 1137 | Args: |
| 1138 | manifest: Snapshot manifest in either shape. |
| 1139 | |
| 1140 | Returns: |
| 1141 | ``dict[str, str]`` of path → SHA-256 hex digest. |
| 1142 | """ |
| 1143 | files = manifest.get("files") if isinstance(manifest, dict) else None |
| 1144 | if isinstance(files, dict): |
| 1145 | return {k: v for k, v in files.items() if isinstance(v, str)} |
| 1146 | if isinstance(manifest, dict): |
| 1147 | return {k: v for k, v in manifest.items() if isinstance(v, str)} |
| 1148 | return {} |
| 1149 | |
| 1150 | |
| 1151 | def merge_vault( |
| 1152 | ours_manifest: dict[str, Any], |
| 1153 | theirs_manifest: dict[str, Any], |
| 1154 | base_manifest: dict[str, Any], |
| 1155 | root: pathlib.Path, |
| 1156 | *, |
| 1157 | attrs: list[AttributeRule] | None = None, |
| 1158 | ) -> tuple[dict[str, str], list[dict[str, str]]]: |
| 1159 | """Three-way merge of two vault snapshots. |
| 1160 | |
| 1161 | For every path in the union of the three manifests: |
| 1162 | |
| 1163 | * Absent in ours and theirs (present in base only) → deleted by both, skip. |
| 1164 | * Added by exactly one side → include the new hash. |
| 1165 | * Same hash in both → unchanged, include. |
| 1166 | * One side unchanged, other modified → include the modified. |
| 1167 | * One side deleted, other modified → delete-edit conflict; |
| 1168 | keep the modified side and emit a conflict entry. |
| 1169 | * Both modified to different hashes → read all three blobs |
| 1170 | from *root* and dispatch :func:`merge_notes`. When the result is |
| 1171 | clean the merged bytes are hashed and recorded; when conflicts are |
| 1172 | present a JSON stub is written to ``.muse/conflicts/`` and a |
| 1173 | conflict entry is emitted. |
| 1174 | |
| 1175 | Args: |
| 1176 | ours_manifest: Ours-side manifest (full ``SnapshotManifest`` or |
| 1177 | bare path-to-hash dict). |
| 1178 | theirs_manifest: Theirs-side manifest. |
| 1179 | base_manifest: Merge-base manifest. |
| 1180 | root: Repository root for blob lookup and stub writing. |
| 1181 | attrs: Optional ``.museattributes`` rule list. |
| 1182 | |
| 1183 | Returns: |
| 1184 | Tuple ``(merged_manifest, conflict_list)``. |
| 1185 | |
| 1186 | - *merged_manifest* maps path → SHA-256 hex digest of the merged |
| 1187 | content. For paths whose merge was a no-op the original digest |
| 1188 | is reused; for content-merged paths the digest is recomputed |
| 1189 | from the merged bytes. |
| 1190 | - *conflict_list* is a list of |
| 1191 | ``{"path", "dimension", "description"}`` dicts (extra fields |
| 1192 | may be present but are not part of the public contract). |
| 1193 | """ |
| 1194 | o_files = _files_of(ours_manifest) |
| 1195 | t_files = _files_of(theirs_manifest) |
| 1196 | b_files = _files_of(base_manifest) |
| 1197 | |
| 1198 | merged: dict[str, str] = {} |
| 1199 | conflict_list: list[dict[str, str]] = [] |
| 1200 | all_paths = set(o_files) | set(t_files) | set(b_files) |
| 1201 | |
| 1202 | for path in sorted(all_paths): |
| 1203 | o_hash = o_files.get(path) |
| 1204 | t_hash = t_files.get(path) |
| 1205 | b_hash = b_files.get(path) |
| 1206 | |
| 1207 | if o_hash is None and t_hash is None: |
| 1208 | continue |
| 1209 | |
| 1210 | if o_hash is None and b_hash is None and t_hash is not None: |
| 1211 | merged[path] = t_hash |
| 1212 | continue |
| 1213 | if t_hash is None and b_hash is None and o_hash is not None: |
| 1214 | merged[path] = o_hash |
| 1215 | continue |
| 1216 | |
| 1217 | if o_hash == t_hash and o_hash is not None: |
| 1218 | merged[path] = o_hash |
| 1219 | continue |
| 1220 | |
| 1221 | if o_hash is None and t_hash == b_hash: |
| 1222 | continue |
| 1223 | if t_hash is None and o_hash == b_hash: |
| 1224 | continue |
| 1225 | |
| 1226 | if o_hash is None and t_hash is not None and b_hash is not None: |
| 1227 | merged[path] = t_hash |
| 1228 | conflict_list.append( |
| 1229 | { |
| 1230 | "path": path, |
| 1231 | "dimension": "file", |
| 1232 | "description": "deleted by ours but modified by theirs", |
| 1233 | } |
| 1234 | ) |
| 1235 | continue |
| 1236 | if t_hash is None and o_hash is not None and b_hash is not None: |
| 1237 | merged[path] = o_hash |
| 1238 | conflict_list.append( |
| 1239 | { |
| 1240 | "path": path, |
| 1241 | "dimension": "file", |
| 1242 | "description": "deleted by theirs but modified by ours", |
| 1243 | } |
| 1244 | ) |
| 1245 | continue |
| 1246 | |
| 1247 | if o_hash == b_hash and t_hash is not None: |
| 1248 | merged[path] = t_hash |
| 1249 | continue |
| 1250 | if t_hash == b_hash and o_hash is not None: |
| 1251 | merged[path] = o_hash |
| 1252 | continue |
| 1253 | |
| 1254 | ours_bytes = _read_blob(root, o_hash) |
| 1255 | theirs_bytes = _read_blob(root, t_hash) |
| 1256 | base_bytes = _read_blob(root, b_hash) |
| 1257 | |
| 1258 | try: |
| 1259 | merged_bytes, note_conflicts = _merge_notes_internal( |
| 1260 | ours_bytes, |
| 1261 | theirs_bytes, |
| 1262 | base_bytes, |
| 1263 | path=path, |
| 1264 | attrs=attrs, |
| 1265 | ) |
| 1266 | except ValueError as exc: |
| 1267 | logger.warning("merge_vault: %s — %s", path, exc) |
| 1268 | merged[path] = o_hash if o_hash is not None else t_hash or "" |
| 1269 | conflict_list.append( |
| 1270 | { |
| 1271 | "path": path, |
| 1272 | "dimension": "file", |
| 1273 | "description": f"merge failed: {exc}", |
| 1274 | } |
| 1275 | ) |
| 1276 | continue |
| 1277 | |
| 1278 | digest = hashlib.sha256(merged_bytes).hexdigest() |
| 1279 | merged[path] = digest |
| 1280 | |
| 1281 | if note_conflicts: |
| 1282 | for c in note_conflicts: |
| 1283 | conflict_list.append( |
| 1284 | { |
| 1285 | "path": c.path, |
| 1286 | "dimension": c.dimension, |
| 1287 | "description": c.description, |
| 1288 | } |
| 1289 | ) |
| 1290 | try: |
| 1291 | _write_conflict_stub(root, note_conflicts) |
| 1292 | except OSError as exc: |
| 1293 | logger.warning( |
| 1294 | "merge_vault: failed to write conflict stub for %s — %s", |
| 1295 | path, |
| 1296 | exc, |
| 1297 | ) |
| 1298 | |
| 1299 | return merged, conflict_list |
| 1300 | |
| 1301 | |
| 1302 | # ────────────────────────────────────────────────────────────────────────────── |
| 1303 | # Public re-exports |
| 1304 | # ────────────────────────────────────────────────────────────────────────────── |
| 1305 | |
| 1306 | __all__ = [ |
| 1307 | "merge_notes", |
| 1308 | "merge_vault", |
| 1309 | ] |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago