merger.py python
1,309 lines 49.7 KB
Raw
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