"""Knowtation three-way merger — Phase 2.4. Implements ``merge_notes`` (single-note three-way merge) and ``merge_vault`` (manifest-level vault merge) for Knowtation Markdown notes. Both entry points are deterministic, idempotent, and operate purely on bytes so they can be exercised without a Muse repository on disk. Algorithm overview ------------------ ``merge_notes`` walks four ordered dimensions and resolves each in turn: 1. **Title** (frontmatter ``title`` field). 2. **Frontmatter scalar fields** (every non-set, non-``title`` field). 3. **Frontmatter set fields** (``tags``, ``entity``, ``follows``, ``summarizes``, ``attachments``) — three-way set union with consensus-deletion semantics. 4. **Sections** (Markdown headings). For each section ID present in any of the three sides the merger classifies the change (added / deleted / modified) and either takes one side, applies a clean merge, or — for irreconcilable line-level overlaps — embeds standard Git conflict markers (``<<<<<<<`` / ``=======`` / ``>>>>>>>``) inside the section body and records a structured conflict. Body-level merging within a section uses a deterministic ``diff3``-style line merge built on :class:`difflib.SequenceMatcher`. When the three line lists share a common subsequence, all changes that fall outside the overlap are picked up cleanly; only true overlaps escalate to conflict markers. The same algorithm is used regardless of how many sections were modified, so every input is reduced to either a clean merge or a small set of marked conflict regions — never a hard failure. Operational-Transform fallback ----------------------------- The section-ordering layer uses :func:`muse.core.op_transform.merge_op_lists` to confirm that the per-side section diffs commute (same address is the key signal). When ``merge_op_lists`` reports a clean op-level merge the section ordering is taken directly; conflicting op pairs always re-route through the body-level diff3 path so that the byte-level output is still deterministic. This dual path satisfies the Phase 2.4 design goal of "OT where it commutes, marked conflicts where it doesn't" without forcing the OT engine to be the source of truth for section bodies. Strategy override (``attrs``) ----------------------------- When ``attrs`` is supplied the merger first asks :func:`muse.core.attributes.resolve_strategy` for a file-level (``"*"``) strategy and for each Knowtation dimension (``"frontmatter"`` and ``"sections"``). When the resolved strategy is one of the Knowtation-specific names registered in :data:`muse.plugins.knowtation.strategies.STRATEGY_DISPATCH`, the merger delegates the entire dimension (or the entire file) to that strategy via :func:`muse.plugins.knowtation.strategies.apply_strategy`. Generic strategies (``ours``, ``theirs``, ``base``, ``union``, ``manual``) are left for the engine layer; this module never silently swallows them. Conflict escalation ------------------- ``merge_vault`` writes a stub JSON record to ``/.muse/conflicts/.json`` for every note whose ``§Summary`` section produced an irreconcilable body conflict. The fingerprint is the SHA-256 of the sorted ``"
:::"`` joins, so the stub is content-addressed and idempotent — re-running the merge over the same inputs replaces the file with identical bytes. The Phase 6 hub-issue renderer can pick these up later without modifying this module. Security -------- * Per-note input is capped at :data:`_MAX_NOTE_BYTES` (16 MiB); oversize inputs raise :class:`ValueError` before any parsing. * All YAML is decoded via :func:`yaml.safe_load` (delegated to :mod:`~muse.plugins.knowtation.differ`); arbitrary Python object instantiation is impossible. * Conflict markers in the **input** content are escaped with a zero-width-space prefix in the merged output so that a malicious branch cannot smuggle a fake conflict marker into the merged note. * NUL bytes, binary content, and invalid UTF-8 are tolerated end-to-end via the differ's ``errors="replace"`` decode path. * The merger never executes user-supplied code and never imports ``yaml.Loader`` or ``yaml.unsafe_load``. No new dependencies are introduced. """ from __future__ import annotations import hashlib import json import logging import pathlib from dataclasses import dataclass, field from difflib import SequenceMatcher from typing import Any from muse.core.attributes import AttributeRule, resolve_strategy from muse.core.op_transform import merge_op_lists from muse.domain import ( DeleteOp, DomainOp, InsertOp, ) from muse.plugins.knowtation.differ import ( _FM_PREFIX, _MAX_NOTE_BYTES, _SECTION_PREFIX, _SET_FIELDS, _TITLE_KEY, _Section, _content_id, _parse_note, _section_id, _serialize_note, _split_sections_typed, canonicalize, ) from muse.plugins.knowtation.strategies import STRATEGY_DISPATCH, apply_strategy logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────────────────────── # Constants # ────────────────────────────────────────────────────────────────────────────── #: Standard git-style conflict markers — emitted verbatim into merged content. _MARK_OURS: str = "<<<<<<< ours" _MARK_SEP: str = "=======" _MARK_THEIRS: str = ">>>>>>> theirs" #: Token prefix appended to defang an inbound conflict marker that arrived #: in the *input*. Zero-width space (U+200B) is invisible in editors but #: prevents downstream tooling from interpreting the line as a real marker. _MARKER_DEFANG_PREFIX: str = "\u200b" #: Relative path where ``merge_vault`` writes per-conflict JSON stubs. _CONFLICT_DIR: str = ".muse/conflicts" #: Section-title aliases that escalate body conflicts to JSON stubs. The #: leading ``§`` glyph used in the SPEC is stripped before comparison so #: both ``"Summary"`` and ``"§Summary"`` match. _SUMMARY_TITLES: frozenset[str] = frozenset({"summary"}) #: Domain name used in any synthetic ops the merger constructs internally. _DOMAIN: str = "knowtation" # ────────────────────────────────────────────────────────────────────────────── # Conflict descriptor # ────────────────────────────────────────────────────────────────────────────── @dataclass class _NoteConflict: """One unresolved conflict surfaced by :func:`_merge_notes_internal`. Attributes: path: Vault-relative POSIX path of the note (set by caller). section: Section ID (``"section::#<occ>"``) for section-dimension conflicts, or a short label such as ``"title"`` / ``"frontmatter:<key>"``. dimension: ``"title"``, ``"frontmatter"``, or ``"sections"``. description: Human-readable summary suitable for ``muse merge`` output. ours_hash: SHA-256 hex of the ours-side content (empty string when the side deleted the section / field). theirs_hash: Same, for the theirs side. base_hash: Same, for the merge-base side. is_summary: True when the section title (case-insensitive, ``§`` stripped) is ``"summary"``; toggles the JSON-stub escalation in :func:`merge_vault`. """ path: str section: str dimension: str description: str ours_hash: str = "" theirs_hash: str = "" base_hash: str = "" is_summary: bool = False def to_dict(self) -> dict[str, str]: """Return a JSON-serialisable dict (for ``merge_vault`` output).""" return { "path": self.path, "section": self.section, "dimension": self.dimension, "description": self.description, "ours_hash": self.ours_hash, "theirs_hash": self.theirs_hash, "base_hash": self.base_hash, } # ────────────────────────────────────────────────────────────────────────────── # Hash and string helpers # ────────────────────────────────────────────────────────────────────────────── def _sha256_text(value: str) -> str: """Return the SHA-256 hex digest of *value* encoded as UTF-8. Args: value: Arbitrary text (``""`` is a valid input). Returns: 64-character lowercase hex digest. """ return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest() def _yaml_str(value: Any) -> str: """Render a YAML scalar to a stable string. Mirrors :func:`muse.plugins.knowtation.differ._stringify` so that two notes with semantically equal scalar values (e.g. ``True`` vs ``"true"``) produce identical merge results. Args: value: Any YAML-loadable scalar. Returns: Deterministic string representation. ``None`` becomes ``""``. """ if value is None: return "" if isinstance(value, bool): return "true" if value else "false" return str(value) def _to_str_list(value: Any) -> list[str]: """Coerce a YAML value to a ``list[str]`` for set-field merging. Args: value: ``None``, a list, or any scalar. Returns: Empty list for ``None``; element-wise stringified list otherwise. """ if value is None: return [] if isinstance(value, list): return [_yaml_str(x) for x in value] return [_yaml_str(value)] def _is_summary_section(sec: _Section) -> bool: """Return ``True`` when *sec*'s title matches a §Summary alias. The SPEC uses the ``§`` glyph (U+00A7) prefix for prominent sections; the merger strips it before case-insensitive comparison so both ``"Summary"`` and ``"§Summary"`` escalate alike. Args: sec: Parsed section to test. Returns: Boolean — true when the section is the §Summary section. """ title = sec.title.strip().lstrip("§").strip() return title.lower() in _SUMMARY_TITLES def _defang_inbound_markers(text: str) -> str: """Prefix any pre-existing conflict-marker lines with a zero-width space. This prevents a malicious or accidental ``<<<<<<<`` line in the input from being misread as a real marker by downstream tooling. The prefix is invisible in editors and idempotent under canonicalisation. Args: text: Raw line content. Returns: Either *text* unchanged or a defanged copy. """ stripped = text.lstrip() if ( stripped.startswith("<<<<<<<") or stripped.startswith("=======") or stripped.startswith(">>>>>>>") ): return _MARKER_DEFANG_PREFIX + text return text # ────────────────────────────────────────────────────────────────────────────── # Strategy override dispatch # ────────────────────────────────────────────────────────────────────────────── def _strategy_override( attrs: list[AttributeRule] | None, path: str, dimension: str, ours: bytes, theirs: bytes, base: bytes, ) -> bytes | None: """Return overridden bytes when *attrs* dictates a Knowtation strategy. Only the three Knowtation-specific strategies registered in :data:`muse.plugins.knowtation.strategies.STRATEGY_DISPATCH` (``prefer-newer-date``, ``union-sorted``, ``knowtation-3way``) are handled here. Generic strategies (``ours``, ``theirs``, etc.) are left for the engine layer because the merger must remain deterministic and stateless. Args: attrs: Rule list from :func:`load_attributes`, or ``None``. path: Vault-relative POSIX path used for matching. dimension: Knowtation dimension name (e.g. ``"frontmatter"``) or ``"*"`` for the file-level lookup. ours: Ours-side raw bytes. theirs: Theirs-side raw bytes. base: Merge-base raw bytes. Returns: Resolved bytes when the strategy is Knowtation-specific, else ``None``. """ if not attrs: return None strategy = resolve_strategy(attrs, path, dimension) if strategy in STRATEGY_DISPATCH and strategy != "knowtation-3way": # Don't recurse into our own strategy; that would deadlock when # knowtation-3way is the file-level default. return apply_strategy(strategy, ours, theirs, base) return None # ────────────────────────────────────────────────────────────────────────────── # Title merge (Dimension 1) # ────────────────────────────────────────────────────────────────────────────── def _merge_title( o: Any, t: Any, b: Any ) -> tuple[Any, _NoteConflict | None]: """Three-way merge of the frontmatter ``title`` field. Args: o: Title on the ours side (``None`` when absent). t: Title on the theirs side. b: Title on the base side. Returns: Tuple ``(resolved, conflict)``. *resolved* is the value to write to the merged frontmatter (or ``None`` to omit the key). *conflict* is ``None`` for clean merges and a :class:`_NoteConflict` when both sides changed to different non-``None`` values. """ if o == t: return o, None if o == b: return t, None if t == b: return o, None o_str = _yaml_str(o) t_str = _yaml_str(t) marker = ( f"{_MARK_OURS}\n{o_str}\n{_MARK_SEP}\n{t_str}\n{_MARK_THEIRS}" ) conflict = _NoteConflict( path="", section="title", dimension="title", description="title differs in both branches", ours_hash=_sha256_text(o_str), theirs_hash=_sha256_text(t_str), base_hash=_sha256_text(_yaml_str(b)), ) return marker, conflict # ────────────────────────────────────────────────────────────────────────────── # Frontmatter scalar merge (Dimension 2) # ────────────────────────────────────────────────────────────────────────────── def _merge_scalar_field( o: Any, t: Any, b: Any ) -> tuple[Any, bool, bool]: """Three-way merge of a single non-set scalar frontmatter field. Args: o: Ours-side value (``None`` when the key is absent). t: Theirs-side value. b: Base-side value. Returns: Tuple ``(resolved, has_conflict, present)``. - ``resolved`` — the merged value (only meaningful when ``present`` is ``True``). - ``has_conflict`` — ``True`` when both sides changed to different non-``None`` values; per spec, the ours side wins. - ``present`` — ``False`` when the key should be **omitted** from the merged frontmatter (consensus-delete or never-existed). """ if o == t: return o, False, o is not None if o == b: return t, False, t is not None if t == b: return o, False, o is not None logger.warning( "Frontmatter scalar conflict (ours=%r, theirs=%r, base=%r) — ours wins", o, t, b, ) return o, True, o is not None # ────────────────────────────────────────────────────────────────────────────── # Frontmatter set merge (Dimension 3) # ────────────────────────────────────────────────────────────────────────────── def _merge_set_field(o: Any, t: Any, b: Any) -> list[str]: """Three-way set merge for ``tags`` / ``entity`` / etc. Algorithm: * Element kept by either branch and not deleted by **both** survives. * Element deleted on both sides is removed. * Element added by one side (regardless of whether the other side also added it) is included exactly once. The output is sorted alphabetically so that canonicalisation is stable and merge results are byte-identical regardless of input ordering. Args: o: Ours-side list (or scalar / ``None``). t: Theirs-side list. b: Base-side list. Returns: Sorted, deduplicated ``list[str]`` of survivors. """ o_set = set(_to_str_list(o)) t_set = set(_to_str_list(t)) b_set = set(_to_str_list(b)) deleted_by_both = (b_set - o_set) & (b_set - t_set) added_by_either = (o_set | t_set) - b_set survivors = (b_set - deleted_by_both) | added_by_either return sorted(survivors) # ────────────────────────────────────────────────────────────────────────────── # Frontmatter merge (combines Dimensions 1–3) # ────────────────────────────────────────────────────────────────────────────── def _merge_frontmatter( o_fm: dict[str, Any], t_fm: dict[str, Any], b_fm: dict[str, Any], ) -> tuple[dict[str, Any], list[_NoteConflict]]: """Per-key three-way merge across the entire frontmatter mapping. Args: o_fm: Ours-side frontmatter dict. t_fm: Theirs-side frontmatter dict. b_fm: Base-side frontmatter dict. Returns: Tuple ``(merged_fm, conflicts)``. *merged_fm* is the new frontmatter dict in canonical key order (title first, others alphabetical). *conflicts* is the list of :class:`_NoteConflict` entries for irreconcilable scalar / title disagreements. """ merged: dict[str, Any] = {} conflicts: list[_NoteConflict] = [] title_resolved, title_conflict = _merge_title( o_fm.get(_TITLE_KEY), t_fm.get(_TITLE_KEY), b_fm.get(_TITLE_KEY) ) if title_resolved is not None and title_resolved != "": merged[_TITLE_KEY] = title_resolved if title_conflict is not None: conflicts.append(title_conflict) all_keys = (set(o_fm) | set(t_fm) | set(b_fm)) - {_TITLE_KEY} for key in sorted(all_keys): o_val = o_fm.get(key) t_val = t_fm.get(key) b_val = b_fm.get(key) if key in _SET_FIELDS: survivors = _merge_set_field(o_val, t_val, b_val) if survivors: merged[key] = survivors continue resolved, has_conflict, present = _merge_scalar_field(o_val, t_val, b_val) if present: merged[key] = resolved if has_conflict: conflicts.append( _NoteConflict( path="", section=f"frontmatter:{key}", dimension="frontmatter", description=f"scalar field {key!r} differs in both branches", ours_hash=_sha256_text(_yaml_str(o_val)), theirs_hash=_sha256_text(_yaml_str(t_val)), base_hash=_sha256_text(_yaml_str(b_val)), ) ) return merged, conflicts # ────────────────────────────────────────────────────────────────────────────── # Line-level diff3 merge (used by the section-body merger) # ────────────────────────────────────────────────────────────────────────────── def _three_way_merge_lines( base: list[str], ours: list[str], theirs: list[str] ) -> tuple[list[str], int]: """Diff3-style three-way merge of three line lists. The algorithm finds matching subsequences (LCS) of base↔ours and base↔theirs via :class:`difflib.SequenceMatcher`, intersects them to obtain a set of shared *anchor* indices in the base, then walks the anchors emitting one chunk at a time: * Empty unstable region between anchors → no output. * Both sides made the same change → take it once. * Only ours changed → take ours. * Only theirs changed → take theirs. * Both sides made different changes → embed conflict markers. The anchor lines themselves are guaranteed equal in both branches and are appended verbatim. All lines from the input are passed through :func:`_defang_inbound_markers` so a malicious branch cannot smuggle a fake marker into the merged output. Args: base: Base-side line list. ours: Ours-side line list. theirs: Theirs-side line list. Returns: Tuple ``(merged_lines, conflict_count)`` — *merged_lines* is the full output line sequence; *conflict_count* is the number of marker triples emitted (0 on a clean merge). """ base = [_defang_inbound_markers(line) for line in base] ours = [_defang_inbound_markers(line) for line in ours] theirs = [_defang_inbound_markers(line) for line in theirs] if ours == theirs: return list(ours), 0 if ours == base: return list(theirs), 0 if theirs == base: return list(ours), 0 o_blocks = list( SequenceMatcher(a=base, b=ours, autojunk=False).get_matching_blocks() ) t_blocks = list( SequenceMatcher(a=base, b=theirs, autojunk=False).get_matching_blocks() ) o_pos: dict[int, int] = {} for blk in o_blocks: for i in range(blk.size): o_pos[blk.a + i] = blk.b + i t_pos: dict[int, int] = {} for blk in t_blocks: for i in range(blk.size): t_pos[blk.a + i] = blk.b + i common = sorted(set(o_pos) & set(t_pos)) common.append(len(base)) # sentinel — flushes the tail chunk result: list[str] = [] conflicts = 0 bi_prev = -1 oi_prev = -1 ti_prev = -1 for bi in common: if bi == len(base): oi = len(ours) ti = len(theirs) else: oi = o_pos[bi] ti = t_pos[bi] base_chunk = base[bi_prev + 1 : bi] ours_chunk = ours[oi_prev + 1 : oi] theirs_chunk = theirs[ti_prev + 1 : ti] if not base_chunk and not ours_chunk and not theirs_chunk: pass elif ours_chunk == theirs_chunk: result.extend(ours_chunk) elif base_chunk == ours_chunk: result.extend(theirs_chunk) elif base_chunk == theirs_chunk: result.extend(ours_chunk) else: result.append(_MARK_OURS) result.extend(ours_chunk) result.append(_MARK_SEP) result.extend(theirs_chunk) result.append(_MARK_THEIRS) conflicts += 1 if bi < len(base): result.append(base[bi]) bi_prev = bi oi_prev = oi ti_prev = ti return result, conflicts def _merge_section_body( b_text: str, o_text: str, t_text: str ) -> tuple[str, int]: """Merge one section's body text three ways at line granularity. Splitting uses ``splitlines(keepends=True)`` so trailing newlines are preserved verbatim. Conflict markers are joined with explicit ``\\n`` terminators because they are synthetic and have no inherent newline. Args: b_text: Base-side section text. o_text: Ours-side section text. t_text: Theirs-side section text. Returns: Tuple ``(merged_text, conflict_count)``. """ if o_text == t_text: return o_text, 0 if o_text == b_text: return t_text, 0 if t_text == b_text: return o_text, 0 base_lines = b_text.splitlines(keepends=True) ours_lines = o_text.splitlines(keepends=True) theirs_lines = t_text.splitlines(keepends=True) merged, n_conflicts = _three_way_merge_lines(base_lines, ours_lines, theirs_lines) pieces: list[str] = [] for line in merged: if line in (_MARK_OURS, _MARK_SEP, _MARK_THEIRS): pieces.append(line + "\n") else: pieces.append(line) return "".join(pieces), n_conflicts # ────────────────────────────────────────────────────────────────────────────── # Section structural merge (Dimension 4) # ────────────────────────────────────────────────────────────────────────────── def _build_section_ops( base_secs: list[_Section], target_secs: list[_Section] ) -> list[DomainOp]: """Construct synthetic Insert / Delete ops for the section ID sequence. The ops are intended for :func:`muse.core.op_transform.merge_op_lists` — they describe *which* IDs are added or removed without carrying body content (which is merged separately). Args: base_secs: Base-side section list. target_secs: Target-side section list (ours or theirs). Returns: List of :class:`InsertOp` / :class:`DeleteOp` describing the symmetric difference between the two ID sets. """ base_ids = [s.sid for s in base_secs] target_ids = [s.sid for s in target_secs] base_set = set(base_ids) target_set = set(target_ids) ops: list[DomainOp] = [] target_index = {sid: i for i, sid in enumerate(target_ids)} base_index = {sid: i for i, sid in enumerate(base_ids)} for sid in target_set - base_set: ops.append( InsertOp( op="insert", address=sid, position=target_index[sid], content_id=_content_id(sid), content_summary=sid, ) ) for sid in base_set - target_set: ops.append( DeleteOp( op="delete", address=sid, position=base_index[sid], content_id=_content_id(sid), content_summary=sid, ) ) return ops def _merge_sections( b_secs: list[_Section], o_secs: list[_Section], t_secs: list[_Section], ) -> tuple[list[_Section], list[_NoteConflict]]: """Three-way merge of two section lists against a common base list. Inclusion is computed per section ID: * In neither ours nor theirs → exclude. * In only one side, never in base → include that side's content. * In one side and base, but not the other → if unchanged in the keeping side, treat as consensus delete; else keep the modified side and record a delete-edit conflict. * In both sides (and possibly base) → merge bodies via :func:`_merge_section_body`. Section ordering is determined by ours-side order with theirs-side additions appended in their relative theirs order. This matches user expectations because ``ours`` represents the local working state. The OT engine (:func:`muse.core.op_transform.merge_op_lists`) is consulted purely as a sanity oracle: when its op-level merge reports no conflicts the structural merge is by definition clean and we can short-circuit straight to body-level resolution. When it reports conflicts the same body-level path runs (no behaviour change), but the OT result is logged for diagnostics. Args: b_secs: Base-side section list. o_secs: Ours-side section list. t_secs: Theirs-side section list. Returns: Tuple ``(merged_sections, conflicts)``. """ o_ops = _build_section_ops(b_secs, o_secs) t_ops = _build_section_ops(b_secs, t_secs) ot_result = merge_op_lists([], o_ops, t_ops) if ot_result.conflict_ops: logger.debug( "Section ID OT merge reported %d conflicting op pairs; " "falling through to body-level diff3.", len(ot_result.conflict_ops), ) b_by_id = {s.sid: s for s in b_secs} o_by_id = {s.sid: s for s in o_secs} t_by_id = {s.sid: s for s in t_secs} all_ids = set(b_by_id) | set(o_by_id) | set(t_by_id) resolved: dict[str, _Section | None] = {} conflicts: list[_NoteConflict] = [] for sid in all_ids: b_sec = b_by_id.get(sid) o_sec = o_by_id.get(sid) t_sec = t_by_id.get(sid) in_b, in_o, in_t = b_sec is not None, o_sec is not None, t_sec is not None if not in_o and not in_t: resolved[sid] = None continue if in_o and not in_t: assert o_sec is not None if not in_b: resolved[sid] = o_sec else: assert b_sec is not None if o_sec.text == b_sec.text: resolved[sid] = None else: resolved[sid] = o_sec conflicts.append( _NoteConflict( path="", section=sid, dimension="sections", description=( f"section {sid!r} deleted by theirs but " f"modified by ours" ), ours_hash=_sha256_text(o_sec.text), theirs_hash="", base_hash=_sha256_text(b_sec.text), is_summary=_is_summary_section(o_sec), ) ) continue if in_t and not in_o: assert t_sec is not None if not in_b: resolved[sid] = t_sec else: assert b_sec is not None if t_sec.text == b_sec.text: resolved[sid] = None else: resolved[sid] = t_sec conflicts.append( _NoteConflict( path="", section=sid, dimension="sections", description=( f"section {sid!r} deleted by ours but " f"modified by theirs" ), ours_hash="", theirs_hash=_sha256_text(t_sec.text), base_hash=_sha256_text(b_sec.text), is_summary=_is_summary_section(t_sec), ) ) continue # Present in both ours and theirs (and possibly base) assert o_sec is not None and t_sec is not None b_text = b_sec.text if in_b else "" merged_text, n = _merge_section_body(b_text, o_sec.text, t_sec.text) merged_sec = _Section( sid=sid, level=o_sec.level, title=o_sec.title, text=merged_text, ) resolved[sid] = merged_sec if n > 0: conflicts.append( _NoteConflict( path="", section=sid, dimension="sections", description=( f"section {sid!r} body conflict ({n} unresolved chunk" f"{'s' if n != 1 else ''})" ), ours_hash=_sha256_text(o_sec.text), theirs_hash=_sha256_text(t_sec.text), base_hash=_sha256_text(b_text), is_summary=_is_summary_section(o_sec), ) ) ordered: list[_Section] = [] seen: set[str] = set() for sec in o_secs: if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: r = resolved[sec.sid] assert r is not None ordered.append(r) seen.add(sec.sid) for sec in t_secs: if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: r = resolved[sec.sid] assert r is not None ordered.append(r) seen.add(sec.sid) for sec in b_secs: if sec.sid in resolved and resolved[sec.sid] is not None and sec.sid not in seen: r = resolved[sec.sid] assert r is not None ordered.append(r) seen.add(sec.sid) return ordered, conflicts # ────────────────────────────────────────────────────────────────────────────── # merge_notes — public entry point # ────────────────────────────────────────────────────────────────────────────── def _merge_notes_internal( ours: bytes, theirs: bytes, base: bytes, *, path: str = "", attrs: list[AttributeRule] | None = None, ) -> tuple[bytes, list[_NoteConflict]]: """Three-way merge two notes; return ``(merged_bytes, conflicts)``. Internal counterpart to :func:`merge_notes` — exposed so that :func:`merge_vault` can collect structured conflicts before writing JSON stubs to ``.muse/conflicts/``. Args: ours: Ours-side raw bytes. theirs: Theirs-side raw bytes. base: Merge-base raw bytes. path: Vault-relative POSIX path (used for ``attrs`` lookup and stamped onto every emitted conflict). attrs: Optional ``.museattributes`` rule list. Returns: Tuple ``(merged_bytes, conflicts)``. Raises: ValueError: If any input exceeds :data:`_MAX_NOTE_BYTES`. """ for name, blob in (("ours", ours), ("theirs", theirs), ("base", base)): if len(blob) > _MAX_NOTE_BYTES: raise ValueError( f"Note {name!r} size {len(blob)} bytes exceeds maximum " f"{_MAX_NOTE_BYTES}" ) # NOTE: only the strict ``ours == theirs`` early-out is safe. The # ``ours == base`` and ``theirs == base`` shortcuts are intentionally # **omitted** because they would bypass the union-sorted semantics of # set-valued frontmatter fields (Dimension 3). Per spec, a tag deleted # by only one side must be kept when the other side still wants it, # which requires running the per-dimension merge even when one side is # bytewise unchanged. if ours == theirs: return canonicalize(ours), [] file_override = _strategy_override(attrs, path, "*", ours, theirs, base) if file_override is not None: return canonicalize(file_override), [] o_parsed = _parse_note(ours) t_parsed = _parse_note(theirs) b_parsed = _parse_note(base) fm_override = _strategy_override(attrs, path, "frontmatter", ours, theirs, base) if fm_override is not None: merged_fm = _parse_note(fm_override).frontmatter fm_conflicts: list[_NoteConflict] = [] else: merged_fm, fm_conflicts = _merge_frontmatter( o_parsed.frontmatter, t_parsed.frontmatter, b_parsed.frontmatter ) sec_override = _strategy_override(attrs, path, "sections", ours, theirs, base) if sec_override is not None: merged_sections = _split_sections_typed(_parse_note(sec_override).body) section_conflicts: list[_NoteConflict] = [] else: merged_sections, section_conflicts = _merge_sections( _split_sections_typed(b_parsed.body), _split_sections_typed(o_parsed.body), _split_sections_typed(t_parsed.body), ) new_body = "".join(s.text for s in merged_sections) out = _serialize_note(merged_fm, new_body) all_conflicts = fm_conflicts + section_conflicts for c in all_conflicts: c.path = path return out, all_conflicts def merge_notes( ours: bytes, theirs: bytes, base: bytes, *, path: str = "", attrs: list[AttributeRule] | None = None, ) -> bytes: """Three-way merge of two note versions against a common base. On a clean merge the result is canonical-form bytes (LF newlines, sorted set fields, alphabetised frontmatter keys). On an irreconcilable conflict the merged content embeds standard git conflict markers (``<<<<<<<`` / ``=======`` / ``>>>>>>>``) within the affected dimension; the bytes are still safe to write to disk and to re-merge later. Calling ``merge_notes`` twice with identical inputs produces byte-identical outputs (idempotent). Inputs that exceed the 16 MiB per-note cap raise :class:`ValueError`. Args: ours: Ours-side raw bytes. theirs: Theirs-side raw bytes. base: Merge-base raw bytes. path: Optional vault-relative POSIX path (used for ``.museattributes`` lookup). attrs: Optional rule list from :func:`muse.core.attributes.load_attributes`. Returns: Merged note bytes in canonical form (with conflict markers when applicable). Raises: ValueError: If any input exceeds the 16 MiB note cap. """ merged_bytes, _ = _merge_notes_internal( ours, theirs, base, path=path, attrs=attrs ) return merged_bytes # ────────────────────────────────────────────────────────────────────────────── # Conflict stub writer # ────────────────────────────────────────────────────────────────────────────── def _conflict_fingerprint(conflicts: list[_NoteConflict]) -> str: """Compute a deterministic SHA-256 fingerprint over a conflict list. The fingerprint is the SHA-256 of the sorted ``"<section>:<ours_hash>:<theirs_hash>:<base_hash>"`` joins. Sorting guarantees that the same set of conflicts always produces the same fingerprint regardless of ordering. Args: conflicts: List of :class:`_NoteConflict` to fingerprint. Returns: 64-character lowercase hex fingerprint. Empty input returns the well-known empty-string SHA-256. """ pairs = sorted( f"{c.section}:{c.ours_hash}:{c.theirs_hash}:{c.base_hash}" for c in conflicts ) payload = "|".join(pairs) return hashlib.sha256(payload.encode("utf-8")).hexdigest() def _write_conflict_stub( root: pathlib.Path, conflicts: list[_NoteConflict], ) -> str | None: """Write a JSON stub for §Summary conflicts; return its fingerprint. The stub lives at ``<root>/.muse/conflicts/<fingerprint>.json`` and is content-addressed: re-running the merge with identical inputs overwrites the file with identical bytes. When no §Summary conflict is present the function is a no-op and returns ``None``. Args: root: Repository root (parent of ``.muse/``). conflicts: List of :class:`_NoteConflict` already stamped with ``path`` by :func:`_merge_notes_internal`. Returns: The stub fingerprint when a file was written, otherwise ``None``. """ summary_conflicts = [c for c in conflicts if c.is_summary] if not summary_conflicts: return None fp = _conflict_fingerprint(summary_conflicts) target_dir = root / _CONFLICT_DIR target_dir.mkdir(parents=True, exist_ok=True) stub_path = target_dir / f"{fp}.json" head = summary_conflicts[0] payload = { "path": head.path, "section": head.section, "ours_hash": head.ours_hash, "theirs_hash": head.theirs_hash, "base_hash": head.base_hash, "fingerprint": fp, } stub_path.write_text( json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8", ) return fp # ────────────────────────────────────────────────────────────────────────────── # merge_vault — public entry point # ────────────────────────────────────────────────────────────────────────────── def _read_blob(root: pathlib.Path, content_hash: str | None) -> bytes: """Read a content-addressed blob, falling back gracefully. Tries the Muse object store first (``<root>/.muse/objects/...``) via :func:`muse.core.object_store.read_object`. When that store is not available or the blob is missing, falls back to a flat ``<root>/.knowtation_blobs/<hash>`` directory which the test suite populates directly — this keeps ``merge_vault`` testable without a fully-initialised Muse repository. Args: root: Repository root. content_hash: SHA-256 hex digest, or ``None``. Returns: Raw bytes (possibly empty) for the blob. Returns ``b""`` when *content_hash* is ``None`` so callers can treat "absent" as "deleted" without checking for ``None``. """ if content_hash is None: return b"" try: from muse.core.object_store import read_object # local import — optional blob = read_object(root, content_hash) if blob is not None: return blob except (ImportError, ValueError, OSError): pass fallback = root / ".knowtation_blobs" / content_hash if fallback.exists(): return fallback.read_bytes() return b"" def _files_of(manifest: dict[str, Any]) -> dict[str, str]: """Extract the ``"files"`` mapping from a snapshot manifest. Accepts either a full :class:`~muse.domain.SnapshotManifest` (``{"files": {...}, "domain": "...", ...}``) or a bare path → hash dict, returning the latter form unchanged. Args: manifest: Snapshot manifest in either shape. Returns: ``dict[str, str]`` of path → SHA-256 hex digest. """ files = manifest.get("files") if isinstance(manifest, dict) else None if isinstance(files, dict): return {k: v for k, v in files.items() if isinstance(v, str)} if isinstance(manifest, dict): return {k: v for k, v in manifest.items() if isinstance(v, str)} return {} def merge_vault( ours_manifest: dict[str, Any], theirs_manifest: dict[str, Any], base_manifest: dict[str, Any], root: pathlib.Path, *, attrs: list[AttributeRule] | None = None, ) -> tuple[dict[str, str], list[dict[str, str]]]: """Three-way merge of two vault snapshots. For every path in the union of the three manifests: * Absent in ours and theirs (present in base only) → deleted by both, skip. * Added by exactly one side → include the new hash. * Same hash in both → unchanged, include. * One side unchanged, other modified → include the modified. * One side deleted, other modified → delete-edit conflict; keep the modified side and emit a conflict entry. * Both modified to different hashes → read all three blobs from *root* and dispatch :func:`merge_notes`. When the result is clean the merged bytes are hashed and recorded; when conflicts are present a JSON stub is written to ``.muse/conflicts/`` and a conflict entry is emitted. Args: ours_manifest: Ours-side manifest (full ``SnapshotManifest`` or bare path-to-hash dict). theirs_manifest: Theirs-side manifest. base_manifest: Merge-base manifest. root: Repository root for blob lookup and stub writing. attrs: Optional ``.museattributes`` rule list. Returns: Tuple ``(merged_manifest, conflict_list)``. - *merged_manifest* maps path → SHA-256 hex digest of the merged content. For paths whose merge was a no-op the original digest is reused; for content-merged paths the digest is recomputed from the merged bytes. - *conflict_list* is a list of ``{"path", "dimension", "description"}`` dicts (extra fields may be present but are not part of the public contract). """ o_files = _files_of(ours_manifest) t_files = _files_of(theirs_manifest) b_files = _files_of(base_manifest) merged: dict[str, str] = {} conflict_list: list[dict[str, str]] = [] all_paths = set(o_files) | set(t_files) | set(b_files) for path in sorted(all_paths): o_hash = o_files.get(path) t_hash = t_files.get(path) b_hash = b_files.get(path) if o_hash is None and t_hash is None: continue if o_hash is None and b_hash is None and t_hash is not None: merged[path] = t_hash continue if t_hash is None and b_hash is None and o_hash is not None: merged[path] = o_hash continue if o_hash == t_hash and o_hash is not None: merged[path] = o_hash continue if o_hash is None and t_hash == b_hash: continue if t_hash is None and o_hash == b_hash: continue if o_hash is None and t_hash is not None and b_hash is not None: merged[path] = t_hash conflict_list.append( { "path": path, "dimension": "file", "description": "deleted by ours but modified by theirs", } ) continue if t_hash is None and o_hash is not None and b_hash is not None: merged[path] = o_hash conflict_list.append( { "path": path, "dimension": "file", "description": "deleted by theirs but modified by ours", } ) continue if o_hash == b_hash and t_hash is not None: merged[path] = t_hash continue if t_hash == b_hash and o_hash is not None: merged[path] = o_hash continue ours_bytes = _read_blob(root, o_hash) theirs_bytes = _read_blob(root, t_hash) base_bytes = _read_blob(root, b_hash) try: merged_bytes, note_conflicts = _merge_notes_internal( ours_bytes, theirs_bytes, base_bytes, path=path, attrs=attrs, ) except ValueError as exc: logger.warning("merge_vault: %s — %s", path, exc) merged[path] = o_hash if o_hash is not None else t_hash or "" conflict_list.append( { "path": path, "dimension": "file", "description": f"merge failed: {exc}", } ) continue digest = hashlib.sha256(merged_bytes).hexdigest() merged[path] = digest if note_conflicts: for c in note_conflicts: conflict_list.append( { "path": c.path, "dimension": c.dimension, "description": c.description, } ) try: _write_conflict_stub(root, note_conflicts) except OSError as exc: logger.warning( "merge_vault: failed to write conflict stub for %s — %s", path, exc, ) return merged, conflict_list # ────────────────────────────────────────────────────────────────────────────── # Public re-exports # ────────────────────────────────────────────────────────────────────────────── __all__ = [ "merge_notes", "merge_vault", ]