domain.py
python
| 1 | """MuseDomainPlugin — the five-interface protocol that defines a Muse domain. |
| 2 | |
| 3 | Muse provides the DAG engine, content-addressed object store, branching, |
| 4 | lineage walking, topological log graph, and merge base finder. A domain plugin |
| 5 | implements these five interfaces and Muse does the rest. |
| 6 | |
| 7 | The music plugin (``muse.plugins.music``) is the reference implementation. |
| 8 | Every other domain — scientific simulation, genomics, 3D spatial design, |
| 9 | spacetime — is a new plugin. |
| 10 | """ |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import pathlib |
| 14 | from dataclasses import dataclass, field |
| 15 | from typing import Protocol, TypedDict, runtime_checkable |
| 16 | |
| 17 | |
| 18 | # --------------------------------------------------------------------------- |
| 19 | # Named snapshot and delta types |
| 20 | # --------------------------------------------------------------------------- |
| 21 | |
| 22 | |
| 23 | class SnapshotManifest(TypedDict): |
| 24 | """Content-addressed snapshot of domain state. |
| 25 | |
| 26 | ``files`` maps workspace-relative POSIX paths to their SHA-256 content |
| 27 | digests. ``domain`` identifies which plugin produced this snapshot. |
| 28 | """ |
| 29 | |
| 30 | files: dict[str, str] |
| 31 | domain: str |
| 32 | |
| 33 | |
| 34 | class DeltaManifest(TypedDict): |
| 35 | """Minimal change description between two snapshots. |
| 36 | |
| 37 | Each list contains workspace-relative POSIX paths. ``domain`` identifies |
| 38 | the plugin that produced this delta. |
| 39 | """ |
| 40 | |
| 41 | domain: str |
| 42 | added: list[str] |
| 43 | removed: list[str] |
| 44 | modified: list[str] |
| 45 | |
| 46 | |
| 47 | # --------------------------------------------------------------------------- |
| 48 | # Type aliases used in the protocol signatures |
| 49 | # --------------------------------------------------------------------------- |
| 50 | |
| 51 | #: Live state is either an already-snapshotted manifest dict or a workdir path. |
| 52 | #: The music plugin accepts both: a Path (for CLI commit/status) and a |
| 53 | #: SnapshotManifest dict (for in-memory merge and diff operations). |
| 54 | LiveState = SnapshotManifest | pathlib.Path |
| 55 | |
| 56 | #: A content-addressed, immutable snapshot of state at a point in time. |
| 57 | StateSnapshot = SnapshotManifest |
| 58 | |
| 59 | #: The minimal change between two snapshots — additions, removals, mutations. |
| 60 | StateDelta = DeltaManifest |
| 61 | |
| 62 | |
| 63 | # --------------------------------------------------------------------------- |
| 64 | # Merge and drift result types |
| 65 | # --------------------------------------------------------------------------- |
| 66 | |
| 67 | |
| 68 | @dataclass |
| 69 | class MergeResult: |
| 70 | """Outcome of a three-way merge between two divergent state lines. |
| 71 | |
| 72 | ``merged`` is the reconciled snapshot. ``conflicts`` is a list of |
| 73 | workspace-relative file paths that could not be auto-merged and require |
| 74 | manual resolution. An empty ``conflicts`` list means the merge was clean. |
| 75 | The CLI is responsible for formatting user-facing messages from these paths. |
| 76 | |
| 77 | ``applied_strategies`` maps each path where a ``.museattributes`` rule |
| 78 | overrode the default conflict behaviour to the strategy that was applied. |
| 79 | Paths absent from this dict were resolved by the standard three-way merge |
| 80 | logic. Example:: |
| 81 | |
| 82 | {"tracks/drums.mid": "ours", "keys/piano.mid": "theirs"} |
| 83 | |
| 84 | ``dimension_reports`` maps conflicting paths to their per-dimension |
| 85 | resolution detail. Each inner dict maps a dimension name to the strategy |
| 86 | or winner chosen for that dimension (e.g. ``{"melodic": "ours", "dynamic": |
| 87 | "theirs"}``). Only populated for files where dimension-level merge was |
| 88 | attempted. |
| 89 | """ |
| 90 | |
| 91 | merged: StateSnapshot |
| 92 | conflicts: list[str] = field(default_factory=list) |
| 93 | applied_strategies: dict[str, str] = field(default_factory=dict) |
| 94 | dimension_reports: dict[str, dict[str, str]] = field(default_factory=dict) |
| 95 | |
| 96 | @property |
| 97 | def is_clean(self) -> bool: |
| 98 | return len(self.conflicts) == 0 |
| 99 | |
| 100 | |
| 101 | @dataclass |
| 102 | class DriftReport: |
| 103 | """Gap between committed state and current live state. |
| 104 | |
| 105 | ``has_drift`` is ``True`` when the live state differs from the committed |
| 106 | snapshot. ``summary`` is a human-readable description of what changed. |
| 107 | ``delta`` is the machine-readable diff for programmatic consumers. |
| 108 | """ |
| 109 | |
| 110 | has_drift: bool |
| 111 | summary: str = "" |
| 112 | delta: StateDelta = field(default_factory=lambda: DeltaManifest( |
| 113 | domain="", added=[], removed=[], modified=[], |
| 114 | )) |
| 115 | |
| 116 | |
| 117 | # --------------------------------------------------------------------------- |
| 118 | # The plugin protocol |
| 119 | # --------------------------------------------------------------------------- |
| 120 | |
| 121 | |
| 122 | @runtime_checkable |
| 123 | class MuseDomainPlugin(Protocol): |
| 124 | """The five interfaces a domain plugin must implement. |
| 125 | |
| 126 | Muse provides everything else: the DAG, branching, checkout, lineage |
| 127 | walking, ASCII log graph, and merge base finder. Implement these five |
| 128 | methods and your domain gets the full Muse VCS for free. |
| 129 | |
| 130 | Music is the reference implementation (``muse.plugins.music``). |
| 131 | """ |
| 132 | |
| 133 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 134 | """Capture current live state as a serialisable, hashable snapshot. |
| 135 | |
| 136 | The returned ``SnapshotManifest`` must be JSON-serialisable. Muse will |
| 137 | compute a SHA-256 content address from the canonical JSON form and |
| 138 | store the snapshot as a blob in ``.muse/objects/``. |
| 139 | |
| 140 | **``.museignore`` contract** — when *live_state* is a |
| 141 | ``pathlib.Path`` (the ``muse-work/`` directory), domain plugin |
| 142 | implementations **must** honour ``.museignore`` by calling |
| 143 | :func:`muse.core.ignore.load_patterns` on the repository root and |
| 144 | filtering out paths matched by :func:`muse.core.ignore.is_ignored`. |
| 145 | This ensures that OS artifacts, build outputs, and domain-specific |
| 146 | scratch files are never committed, regardless of which plugin is active. |
| 147 | See ``docs/reference/museignore.md`` for the full format reference. |
| 148 | """ |
| 149 | ... |
| 150 | |
| 151 | def diff(self, base: StateSnapshot, target: StateSnapshot) -> StateDelta: |
| 152 | """Compute the minimal delta between two snapshots. |
| 153 | |
| 154 | Returns a ``DeltaManifest`` listing which paths were added, removed, |
| 155 | or modified. Muse stores deltas alongside commits so that ``muse show`` |
| 156 | can display a human-readable summary without reloading full blobs. |
| 157 | """ |
| 158 | ... |
| 159 | |
| 160 | def merge( |
| 161 | self, |
| 162 | base: StateSnapshot, |
| 163 | left: StateSnapshot, |
| 164 | right: StateSnapshot, |
| 165 | *, |
| 166 | repo_root: pathlib.Path | None = None, |
| 167 | ) -> MergeResult: |
| 168 | """Three-way merge two divergent state lines against a common base. |
| 169 | |
| 170 | ``base`` is the common ancestor (merge base). ``left`` and ``right`` |
| 171 | are the two divergent snapshots. Returns a ``MergeResult`` with the |
| 172 | reconciled snapshot and any unresolvable conflicts. |
| 173 | |
| 174 | **``.museattributes`` and multidimensional merge contract** — when |
| 175 | *repo_root* is provided, domain plugin implementations should: |
| 176 | |
| 177 | 1. Load ``.museattributes`` via |
| 178 | :func:`muse.core.attributes.load_attributes`. |
| 179 | 2. For each conflicting path, call |
| 180 | :func:`muse.core.attributes.resolve_strategy` with the relevant |
| 181 | dimension name (or ``"*"`` for file-level resolution). |
| 182 | 3. Apply the returned strategy: |
| 183 | |
| 184 | - ``"ours"`` — take the *left* version; remove from conflict list. |
| 185 | - ``"theirs"`` — take the *right* version; remove from conflict list. |
| 186 | - ``"manual"`` — force into conflict list even if the engine would |
| 187 | auto-resolve. |
| 188 | - ``"auto"`` / ``"union"`` — defer to the engine's default logic. |
| 189 | |
| 190 | 4. For domain formats that support true multidimensional content (e.g. |
| 191 | MIDI: melodic, rhythmic, harmonic, dynamic, structural), attempt |
| 192 | sub-file dimension merge before falling back to a file-level conflict. |
| 193 | |
| 194 | Record every override in :attr:`MergeResult.applied_strategies` and |
| 195 | per-dimension detail in :attr:`MergeResult.dimension_reports`. See |
| 196 | ``docs/reference/muse-attributes.md`` for the full format reference. |
| 197 | """ |
| 198 | ... |
| 199 | |
| 200 | def drift( |
| 201 | self, |
| 202 | committed: StateSnapshot, |
| 203 | live: LiveState, |
| 204 | ) -> DriftReport: |
| 205 | """Compare committed state against current live state. |
| 206 | |
| 207 | Used by ``muse status`` to detect uncommitted changes. Returns a |
| 208 | ``DriftReport`` describing whether the live state has diverged from |
| 209 | the last committed snapshot and, if so, by how much. |
| 210 | """ |
| 211 | ... |
| 212 | |
| 213 | def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: |
| 214 | """Apply a delta to produce a new live state. |
| 215 | |
| 216 | Used by ``muse checkout`` to reconstruct a historical state. Applies |
| 217 | ``delta`` on top of ``live_state`` and returns the resulting state. |
| 218 | """ |
| 219 | ... |