plugin.py
python
sha256:f0cc3f3fe40f1df9a488585b5b2aef6aa4dcb0b22b5fdbf7bab7071a5b0c7f7c
feat(merge): Phase 7 — independence merge correctness + Har…
Sonnet 4.6
minor
⚠ breaking
35 days ago
| 1 | """Code domain plugin — semantic version control for source code. |
| 2 | |
| 3 | This plugin implements :class:`~muse.domain.MuseDomainPlugin` and |
| 4 | :class:`~muse.domain.StructuredMergePlugin` for software repositories. |
| 5 | |
| 6 | Philosophy |
| 7 | ---------- |
| 8 | Git models files as sequences of lines. The code plugin models them as |
| 9 | **collections of named symbols** — functions, classes, methods, variables. |
| 10 | Two commits that only reformat a Python file (no semantic change) produce |
| 11 | identical symbol ``content_id`` values and therefore *no* structured delta. |
| 12 | Two commits that rename a function produce a ``ReplaceOp`` annotated |
| 13 | ``"renamed to bar"`` rather than a red/green line diff. |
| 14 | |
| 15 | Live State |
| 16 | ---------- |
| 17 | ``LiveState`` is either a ``pathlib.Path`` pointing to the repository root or a |
| 18 | ``SnapshotManifest`` dict. The path form is used by the CLI; the dict form |
| 19 | is used by in-memory merge and diff operations. |
| 20 | |
| 21 | Snapshot Format |
| 22 | --------------- |
| 23 | A code snapshot is a ``SnapshotManifest``: |
| 24 | |
| 25 | .. code-block:: json |
| 26 | |
| 27 | { |
| 28 | "files": { |
| 29 | "src/utils.py": "<sha256-of-raw-bytes>", |
| 30 | "README.md": "<sha256-of-raw-bytes>" |
| 31 | }, |
| 32 | "domain": "code" |
| 33 | } |
| 34 | |
| 35 | The ``files`` values are **raw-bytes SHA-256 hashes** (not AST hashes). |
| 36 | This ensures the object store can correctly restore files verbatim on |
| 37 | ``muse checkout``. Semantic identity (AST-based hashing) is used only |
| 38 | inside ``diff()`` when constructing the structured delta. |
| 39 | |
| 40 | Delta Format |
| 41 | ------------ |
| 42 | ``diff()`` returns a ``StructuredDelta``. For Python files (and other |
| 43 | languages with adapters) it produces ``PatchOp`` entries whose ``child_ops`` |
| 44 | carry symbol-level operations: |
| 45 | |
| 46 | - ``InsertOp`` — a symbol was added (address ``"src/utils.py::my_func"``). |
| 47 | - ``DeleteOp`` — a symbol was removed. |
| 48 | - ``ReplaceOp`` — a symbol changed. The ``new_summary`` field describes the |
| 49 | change: ``"renamed to bar"``, ``"implementation changed"``, etc. |
| 50 | |
| 51 | Non-Python files produce coarse ``InsertOp`` / ``DeleteOp`` / ``ReplaceOp`` |
| 52 | at the file level. |
| 53 | |
| 54 | Merge Semantics |
| 55 | --------------- |
| 56 | The plugin implements :class:`~muse.domain.StructuredMergePlugin` so that |
| 57 | OT-aware merges detect conflicts at *symbol* granularity: |
| 58 | |
| 59 | - Agent A modifies ``foo()`` and Agent B modifies ``bar()`` in the same |
| 60 | file → **auto-merge** (ops commute). |
| 61 | - Both agents modify ``foo()`` → **symbol-level conflict** at address |
| 62 | ``"src/utils.py::foo"`` rather than a coarse file conflict. |
| 63 | |
| 64 | Schema |
| 65 | ------ |
| 66 | The code domain schema declares five dimensions: |
| 67 | |
| 68 | ``structure`` |
| 69 | The module/file tree — ``TreeSchema`` with GumTree diff. |
| 70 | |
| 71 | ``symbols`` |
| 72 | The AST symbol tree — ``TreeSchema`` with GumTree diff. |
| 73 | |
| 74 | ``imports`` |
| 75 | The import set — ``SetSchema`` with ``by_content`` identity. |
| 76 | |
| 77 | ``variables`` |
| 78 | Top-level variable assignments — ``SetSchema``. |
| 79 | |
| 80 | ``metadata`` |
| 81 | Configuration and non-code files — ``SetSchema``. |
| 82 | """ |
| 83 | |
| 84 | from __future__ import annotations |
| 85 | |
| 86 | import hashlib |
| 87 | import logging |
| 88 | import os |
| 89 | import pathlib |
| 90 | import stat as _stat |
| 91 | |
| 92 | from muse._version import __version__ |
| 93 | from muse.core.attributes import load_attributes, resolve_strategy |
| 94 | from muse.core.diff_algorithms import snapshot_diff |
| 95 | from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns |
| 96 | from muse.core.snapshot import ( |
| 97 | _BUILTIN_SECRET_PATTERNS, |
| 98 | detect_directory_renames, |
| 99 | directories_from_manifest, |
| 100 | load_ignore_patterns, |
| 101 | ) |
| 102 | from muse.core.object_store import read_object |
| 103 | from muse.core.op_transform import merge_op_lists, ops_commute |
| 104 | from muse.core.stat_cache import load_cache |
| 105 | from muse.core.schema import ( |
| 106 | DimensionSpec, |
| 107 | DomainSchema, |
| 108 | SetSchema, |
| 109 | TreeSchema, |
| 110 | ) |
| 111 | from muse.domain import ( |
| 112 | DeleteOp, |
| 113 | DirectoryRenameOp, |
| 114 | DomainOp, |
| 115 | DriftReport, |
| 116 | InsertOp, |
| 117 | LiveState, |
| 118 | MergeResult, |
| 119 | PatchOp, |
| 120 | SnapshotManifest, |
| 121 | StagedEntry, |
| 122 | StageStatus, |
| 123 | StateDelta, |
| 124 | StateSnapshot, |
| 125 | StructuredDelta, |
| 126 | ) |
| 127 | from muse.plugins.code.stage import ( |
| 128 | clear_stage, |
| 129 | read_stage, |
| 130 | stage_path, |
| 131 | write_stage, |
| 132 | ) |
| 133 | from muse.plugins.code.ast_parser import ( |
| 134 | SymbolTree, |
| 135 | parse_symbols, |
| 136 | ) |
| 137 | from muse.plugins.code.symbol_diff import ( |
| 138 | build_diff_ops, |
| 139 | delta_summary, |
| 140 | ) |
| 141 | from muse.core.store import Manifest |
| 142 | |
| 143 | logger = logging.getLogger(__name__) |
| 144 | |
| 145 | type FileStrMap = dict[str, str] # file_path → hash/content (generic manifest) |
| 146 | type SymbolTreeMap = dict[str, SymbolTree] # file_path → symbol tree |
| 147 | type PatchOpMap = dict[str, PatchOp] # address → patch op |
| 148 | type AppliedStrategies = dict[str, str] # file_path → strategy name |
| 149 | type StagedEntryMap = dict[str, StagedEntry] # file_path → staged entry |
| 150 | |
| 151 | _DOMAIN_NAME = "code" |
| 152 | |
| 153 | # Directories that are never versioned regardless of .museignore. |
| 154 | # These are implicit ignores that apply to all code repositories. |
| 155 | _ALWAYS_IGNORE_DIRS: frozenset[str] = frozenset({ |
| 156 | ".git", |
| 157 | ".muse", |
| 158 | "__pycache__", |
| 159 | ".mypy_cache", |
| 160 | ".pytest_cache", |
| 161 | ".ruff_cache", |
| 162 | "node_modules", |
| 163 | ".venv", |
| 164 | "venv", |
| 165 | ".tox", |
| 166 | ".nox", |
| 167 | ".coverage", |
| 168 | "htmlcov", |
| 169 | "dist", |
| 170 | "build", |
| 171 | ".eggs", |
| 172 | ".DS_Store", |
| 173 | }) |
| 174 | |
| 175 | |
| 176 | def _head_manifest_for(root: pathlib.Path) -> Manifest: |
| 177 | """Return the manifest from the current HEAD commit (empty dict if none). |
| 178 | |
| 179 | Resolves the branch through ``get_head_commit_id`` (the store |
| 180 | abstraction) rather than reading the ref file directly. |
| 181 | |
| 182 | Used by ``snapshot()`` (to build the staged manifest) and ``stage_status()`` |
| 183 | (to compute the unstaged diff). Kept outside the class so it can be called |
| 184 | from module-level helpers without a plugin instance. |
| 185 | """ |
| 186 | from muse.core.store import ( |
| 187 | get_head_commit_id, |
| 188 | read_commit, |
| 189 | read_current_branch, |
| 190 | read_snapshot, |
| 191 | ) |
| 192 | |
| 193 | try: |
| 194 | branch = read_current_branch(root) |
| 195 | commit_id = get_head_commit_id(root, branch) |
| 196 | if not commit_id: |
| 197 | return {} |
| 198 | commit = read_commit(root, commit_id) |
| 199 | if commit is None: |
| 200 | return {} |
| 201 | snap = read_snapshot(root, commit.snapshot_id) |
| 202 | return dict(snap.manifest) if snap else {} |
| 203 | except Exception: |
| 204 | return {} |
| 205 | |
| 206 | |
| 207 | class CodePlugin: |
| 208 | """Muse domain plugin for software source code repositories. |
| 209 | |
| 210 | Implements all six core protocol methods plus the optional |
| 211 | :class:`~muse.domain.StructuredMergePlugin` OT extension and the |
| 212 | :class:`~muse.domain.StagePlugin` selective-commit extension. The plugin |
| 213 | does not implement :class:`~muse.domain.CRDTPlugin` — source code is |
| 214 | human-authored and benefits from explicit conflict resolution rather |
| 215 | than automatic convergence. |
| 216 | |
| 217 | The plugin is stateless. The module-level singleton :data:`plugin` is |
| 218 | the standard entry point. |
| 219 | """ |
| 220 | |
| 221 | # ------------------------------------------------------------------ |
| 222 | # 1. snapshot |
| 223 | # ------------------------------------------------------------------ |
| 224 | |
| 225 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 226 | """Capture the current working tree as a snapshot dict. |
| 227 | |
| 228 | Walks all regular files under *live_state*, hashing each one with |
| 229 | SHA-256 (raw bytes). Honours ``.museignore`` and always ignores |
| 230 | known tool-generated directories (``__pycache__``, ``.git``, etc.). |
| 231 | |
| 232 | Uses ``os.walk`` with in-place ``dirnames`` pruning so that |
| 233 | always-ignored and hidden directories (e.g. ``.venv/``, ``node_modules/``, |
| 234 | ``.muse/``) are never descended into. The ``StatCache`` is consulted |
| 235 | before hashing so that unchanged files are not re-read from disk. |
| 236 | |
| 237 | Args: |
| 238 | live_state: A ``pathlib.Path`` pointing to the repository root, or an |
| 239 | existing ``SnapshotManifest`` dict (returned as-is). |
| 240 | |
| 241 | Returns: |
| 242 | A ``SnapshotManifest`` mapping workspace-relative POSIX paths to |
| 243 | their SHA-256 raw-bytes digests. |
| 244 | """ |
| 245 | if not isinstance(live_state, pathlib.Path): |
| 246 | return live_state |
| 247 | |
| 248 | workdir = live_state |
| 249 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME) |
| 250 | cache = load_cache(workdir) |
| 251 | files: Manifest = {} |
| 252 | musekeep_dirs: set[str] = set() |
| 253 | root_str = str(workdir) |
| 254 | prefix_len = len(root_str) + 1 |
| 255 | |
| 256 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 257 | rel_dir = "" |
| 258 | if dirpath != root_str: |
| 259 | rel_dir = dirpath[prefix_len:] |
| 260 | if os.sep != "/": |
| 261 | rel_dir = rel_dir.replace(os.sep, "/") |
| 262 | |
| 263 | # Prune subdirectories: skip _ALWAYS_IGNORE_DIRS, directories whose |
| 264 | # contents are entirely ignored by .museignore, and nested muse |
| 265 | # repos. Nested repos are independent version-controlled units — |
| 266 | # their files belong to their own snapshot, not the parent repo's. |
| 267 | # Using a sentinel file path (e.g. ".hypothesis/_") lets is_ignored |
| 268 | # detect both directory patterns (.hypothesis/) and glob patterns |
| 269 | # (.hypothesis/**) without descending into the directory first. |
| 270 | dirnames[:] = sorted( |
| 271 | d for d in dirnames |
| 272 | if d not in _ALWAYS_IGNORE_DIRS |
| 273 | and not is_ignored( |
| 274 | f"{rel_dir}/{d}/_" if rel_dir else f"{d}/_", patterns |
| 275 | ) |
| 276 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 277 | ) |
| 278 | |
| 279 | for fname in sorted(filenames): |
| 280 | abs_str = os.path.join(dirpath, fname) |
| 281 | try: |
| 282 | st = os.lstat(abs_str) |
| 283 | except OSError: |
| 284 | continue |
| 285 | if not _stat.S_ISREG(st.st_mode): |
| 286 | continue |
| 287 | rel = abs_str[prefix_len:] |
| 288 | if os.sep != "/": |
| 289 | rel = rel.replace(os.sep, "/") |
| 290 | if is_ignored(rel, patterns): |
| 291 | continue |
| 292 | files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino) |
| 293 | if fname == ".musekeep" and rel_dir: |
| 294 | musekeep_dirs.add(rel_dir) |
| 295 | |
| 296 | cache.prune(set(files)) |
| 297 | cache.save() |
| 298 | |
| 299 | # If a stage index is active, filter the manifest so that only staged |
| 300 | # files (using their staged object IDs) and previously-committed files |
| 301 | # (at their committed state) are included. This is the core of the |
| 302 | # selective-commit model: unstaged working-tree changes are invisible |
| 303 | # to ``muse commit``. |
| 304 | stage = read_stage(workdir) |
| 305 | if stage: |
| 306 | committed = _head_manifest_for(workdir) |
| 307 | staged_manifest: Manifest = {} |
| 308 | staged_manifest.update(committed) |
| 309 | for rel_path, entry in stage.items(): |
| 310 | if entry["mode"] == "D": |
| 311 | staged_manifest.pop(rel_path, None) |
| 312 | else: |
| 313 | staged_manifest[rel_path] = entry["object_id"] |
| 314 | staged_dirs = directories_from_manifest(staged_manifest) |
| 315 | return SnapshotManifest(files=staged_manifest, domain=_DOMAIN_NAME, directories=staged_dirs) |
| 316 | |
| 317 | # Only include directories that contain tracked files or an explicit |
| 318 | # .musekeep marker. Empty directories left behind by deletions must |
| 319 | # not pollute the snapshot — they would appear as phantom "added" |
| 320 | # entries in `muse status`. |
| 321 | dirs = sorted(set(directories_from_manifest(files)) | musekeep_dirs) |
| 322 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=dirs) |
| 323 | |
| 324 | def workdir_snapshot(self, root: pathlib.Path) -> SnapshotManifest: |
| 325 | """Capture the raw working tree, bypassing any active stage. |
| 326 | |
| 327 | Identical to :meth:`snapshot` but skips the stage-overlay logic, |
| 328 | so every on-disk file is reflected at its current content hash. |
| 329 | Used by ``muse diff --working``. |
| 330 | """ |
| 331 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(root), _DOMAIN_NAME) |
| 332 | cache = load_cache(root) |
| 333 | files: Manifest = {} |
| 334 | root_str = str(root) |
| 335 | prefix_len = len(root_str) + 1 |
| 336 | |
| 337 | musekeep_wdirs: set[str] = set() |
| 338 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 339 | rel_dir = "" |
| 340 | if dirpath != root_str: |
| 341 | rel_dir = dirpath[prefix_len:] |
| 342 | if os.sep != "/": |
| 343 | rel_dir = rel_dir.replace(os.sep, "/") |
| 344 | dirnames[:] = sorted( |
| 345 | d for d in dirnames |
| 346 | if d not in _ALWAYS_IGNORE_DIRS |
| 347 | and not is_ignored( |
| 348 | f"{rel_dir}/{d}/_" if rel_dir else f"{d}/_", patterns |
| 349 | ) |
| 350 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 351 | ) |
| 352 | for fname in sorted(filenames): |
| 353 | abs_str = os.path.join(dirpath, fname) |
| 354 | try: |
| 355 | st = os.lstat(abs_str) |
| 356 | except OSError: |
| 357 | continue |
| 358 | if not _stat.S_ISREG(st.st_mode): |
| 359 | continue |
| 360 | rel = abs_str[prefix_len:] |
| 361 | if os.sep != "/": |
| 362 | rel = rel.replace(os.sep, "/") |
| 363 | if is_ignored(rel, patterns): |
| 364 | continue |
| 365 | files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino) |
| 366 | if fname == ".musekeep" and rel_dir: |
| 367 | musekeep_wdirs.add(rel_dir) |
| 368 | |
| 369 | # Only track directories that contain files or an explicit .musekeep |
| 370 | # marker. Empty directories left behind by deletions must not appear |
| 371 | # in the snapshot — they would show up as phantom "added" entries in |
| 372 | # `muse status`. |
| 373 | wdirs = sorted(set(directories_from_manifest(files)) | musekeep_wdirs) |
| 374 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=wdirs) |
| 375 | |
| 376 | # ------------------------------------------------------------------ |
| 377 | # StagePlugin implementation |
| 378 | # ------------------------------------------------------------------ |
| 379 | |
| 380 | def stage_index_path(self, root: pathlib.Path) -> pathlib.Path: |
| 381 | """Return the absolute path of ``.muse/code/stage.json``.""" |
| 382 | return stage_path(root) |
| 383 | |
| 384 | def read_stage(self, root: pathlib.Path) -> StagedEntryMap: |
| 385 | """Read the code-domain stage index.""" |
| 386 | return read_stage(root) |
| 387 | |
| 388 | def write_stage( |
| 389 | self, root: pathlib.Path, entries: StagedEntryMap |
| 390 | ) -> None: |
| 391 | """Persist *entries* as the code-domain stage index.""" |
| 392 | write_stage(root, entries) |
| 393 | |
| 394 | def clear_stage(self, root: pathlib.Path) -> None: |
| 395 | """Remove the code-domain stage index.""" |
| 396 | clear_stage(root) |
| 397 | |
| 398 | def stage_status(self, root: pathlib.Path) -> StageStatus: |
| 399 | """Return a three-bucket view of the working tree vs the stage. |
| 400 | |
| 401 | Compares: |
| 402 | |
| 403 | 1. The current stage against HEAD → **staged** bucket. |
| 404 | 2. Working tree files against their HEAD/stage state → **unstaged**. |
| 405 | 3. Files present on disk but neither tracked nor staged → **untracked**. |
| 406 | """ |
| 407 | stage = read_stage(root) |
| 408 | committed = _head_manifest_for(root) |
| 409 | |
| 410 | # Staged bucket: everything in the stage index. |
| 411 | staged: StagedEntryMap = dict(stage) |
| 412 | |
| 413 | # Build the full working-tree manifest (reuse snapshot logic). |
| 414 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(root), _DOMAIN_NAME) |
| 415 | cache = load_cache(root) |
| 416 | workdir_files: Manifest = {} |
| 417 | root_str = str(root) |
| 418 | prefix_len = len(root_str) + 1 |
| 419 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 420 | dirnames[:] = sorted(d for d in dirnames if d not in _ALWAYS_IGNORE_DIRS) |
| 421 | for fname in sorted(filenames): |
| 422 | abs_str = os.path.join(dirpath, fname) |
| 423 | try: |
| 424 | st = os.lstat(abs_str) |
| 425 | except OSError: |
| 426 | continue |
| 427 | if not _stat.S_ISREG(st.st_mode): |
| 428 | continue |
| 429 | rel = abs_str[prefix_len:] |
| 430 | if os.sep != "/": |
| 431 | rel = rel.replace(os.sep, "/") |
| 432 | if is_ignored(rel, patterns): |
| 433 | continue |
| 434 | workdir_files[rel] = cache.get_cached( |
| 435 | rel, abs_str, st.st_mtime, st.st_size, st.st_ino |
| 436 | ) |
| 437 | |
| 438 | # Unstaged: files whose working-tree content diverges from what is staged |
| 439 | # (or from HEAD for untracked-by-stage files). |
| 440 | # |
| 441 | # Two passes: |
| 442 | # 1. Staged files: compare working tree against the staged object. |
| 443 | # A file modified after staging must appear in the unstaged bucket. |
| 444 | # 2. Committed files not in the stage: compare against HEAD. |
| 445 | unstaged: Manifest = {} |
| 446 | |
| 447 | for rel_path, staged_entry in stage.items(): |
| 448 | if staged_entry["mode"] == "D": |
| 449 | # Staged for deletion — if the file reappeared on disk, flag it. |
| 450 | if rel_path in workdir_files: |
| 451 | unstaged[rel_path] = "modified" |
| 452 | continue |
| 453 | staged_oid = staged_entry["object_id"] |
| 454 | current_id = workdir_files.get(rel_path) |
| 455 | if current_id is None: |
| 456 | # Staged but deleted from disk since staging. |
| 457 | unstaged[rel_path] = "deleted" |
| 458 | elif current_id != staged_oid: |
| 459 | # Modified on disk after staging — not yet re-staged. |
| 460 | unstaged[rel_path] = "modified" |
| 461 | |
| 462 | for rel_path, committed_id in committed.items(): |
| 463 | if rel_path in stage: |
| 464 | continue # already covered in the stage pass above |
| 465 | current_id = workdir_files.get(rel_path) |
| 466 | if current_id is None: |
| 467 | unstaged[rel_path] = "deleted" |
| 468 | elif current_id != committed_id: |
| 469 | unstaged[rel_path] = "modified" |
| 470 | |
| 471 | # Untracked: on disk, not committed, not staged. |
| 472 | all_known = set(committed) | set(stage) |
| 473 | untracked: list[str] = sorted( |
| 474 | rel_path for rel_path in workdir_files if rel_path not in all_known |
| 475 | ) |
| 476 | |
| 477 | return StageStatus(staged=staged, unstaged=unstaged, untracked=untracked) |
| 478 | |
| 479 | # ------------------------------------------------------------------ |
| 480 | # 2. diff |
| 481 | # ------------------------------------------------------------------ |
| 482 | |
| 483 | def diff( |
| 484 | self, |
| 485 | base: StateSnapshot, |
| 486 | target: StateSnapshot, |
| 487 | *, |
| 488 | repo_root: pathlib.Path | None = None, |
| 489 | ) -> StateDelta: |
| 490 | """Compute the structured delta between two snapshots. |
| 491 | |
| 492 | Without ``repo_root`` |
| 493 | Produces coarse file-level ops (``InsertOp`` / ``DeleteOp`` / |
| 494 | ``ReplaceOp``). Used by ``muse checkout`` which only needs file |
| 495 | paths. |
| 496 | |
| 497 | With ``repo_root`` |
| 498 | Reads source bytes from the object store, parses AST for |
| 499 | supported languages (Python), and produces ``PatchOp`` entries |
| 500 | with symbol-level ``child_ops``. Used by ``muse commit`` (to |
| 501 | store the structured delta) and ``muse show`` / ``muse diff``. |
| 502 | |
| 503 | Args: |
| 504 | base: Base snapshot (older state). |
| 505 | target: Target snapshot (newer state). |
| 506 | repo_root: Repository root for object-store access and symbol |
| 507 | extraction. ``None`` → file-level ops only. |
| 508 | |
| 509 | Returns: |
| 510 | A ``StructuredDelta`` with ``domain="code"``. |
| 511 | """ |
| 512 | base_files = base["files"] |
| 513 | target_files = target["files"] |
| 514 | base_dirs = list(base.get("directories") or []) |
| 515 | target_dirs = list(target.get("directories") or []) |
| 516 | |
| 517 | # ── Directory rename detection ──────────────────────────────────────── |
| 518 | # Detect which added/deleted directories are actually renames and |
| 519 | # suppress the corresponding file-level insert/delete ops so the delta |
| 520 | # is clean: one DirectoryRenameOp instead of N inserts + N deletes. |
| 521 | added_dirs = set(target_dirs) - set(base_dirs) |
| 522 | deleted_dirs = set(base_dirs) - set(target_dirs) |
| 523 | dir_renames = detect_directory_renames( |
| 524 | deleted_dirs, added_dirs, base_files, target_files |
| 525 | ) |
| 526 | |
| 527 | # Build the set of file paths covered by directory renames so we can |
| 528 | # filter them out of the file-level diff inputs. |
| 529 | covered: set[str] = set() |
| 530 | for old_dir, new_dir in dir_renames: |
| 531 | old_prefix = old_dir + "/" |
| 532 | new_prefix = new_dir + "/" |
| 533 | covered |= {p for p in base_files if p.startswith(old_prefix)} |
| 534 | covered |= {p for p in target_files if p.startswith(new_prefix)} |
| 535 | |
| 536 | renamed_old = {old for old, _ in dir_renames} |
| 537 | renamed_new = {new for _, new in dir_renames} |
| 538 | |
| 539 | # Build directory-level ops. |
| 540 | dir_ops: list[DomainOp] = [] |
| 541 | for old_dir, new_dir in dir_renames: |
| 542 | file_count = sum(1 for p in target_files if p.startswith(new_dir + "/")) |
| 543 | dir_ops.append( |
| 544 | DirectoryRenameOp( |
| 545 | op="directory_rename", |
| 546 | address=new_dir, |
| 547 | from_address=old_dir, |
| 548 | file_count=file_count, |
| 549 | ) |
| 550 | ) |
| 551 | for d in sorted(added_dirs - renamed_new): |
| 552 | dir_ops.append(InsertOp(op="insert", address=d, position=None, content_id="", content_summary=f"directory: {d}")) |
| 553 | for d in sorted(deleted_dirs - renamed_old): |
| 554 | dir_ops.append(DeleteOp(op="delete", address=d, position=None, content_id="", content_summary=f"directory: {d}")) |
| 555 | |
| 556 | # ── File-level diff (excluding covered paths) ───────────────────────── |
| 557 | if covered: |
| 558 | filtered_base = SnapshotManifest( |
| 559 | files={k: v for k, v in base_files.items() if k not in covered}, |
| 560 | domain=base["domain"], |
| 561 | directories=[], |
| 562 | ) |
| 563 | filtered_target = SnapshotManifest( |
| 564 | files={k: v for k, v in target_files.items() if k not in covered}, |
| 565 | domain=target["domain"], |
| 566 | directories=[], |
| 567 | ) |
| 568 | else: |
| 569 | filtered_base = base |
| 570 | filtered_target = target |
| 571 | |
| 572 | if repo_root is None: |
| 573 | file_delta = snapshot_diff(self.schema(), filtered_base, filtered_target) |
| 574 | file_ops: list[DomainOp] = list(file_delta.get("ops", [])) |
| 575 | else: |
| 576 | file_ops = _semantic_ops( |
| 577 | filtered_base["files"], filtered_target["files"], |
| 578 | repo_root, workdir=repo_root, |
| 579 | ) |
| 580 | |
| 581 | all_ops = dir_ops + file_ops |
| 582 | summary = delta_summary(all_ops) |
| 583 | return StructuredDelta(domain=_DOMAIN_NAME, ops=all_ops, summary=summary) |
| 584 | |
| 585 | # ------------------------------------------------------------------ |
| 586 | # 3. merge |
| 587 | # ------------------------------------------------------------------ |
| 588 | |
| 589 | def merge( |
| 590 | self, |
| 591 | base: StateSnapshot, |
| 592 | left: StateSnapshot, |
| 593 | right: StateSnapshot, |
| 594 | *, |
| 595 | repo_root: pathlib.Path | None = None, |
| 596 | ) -> MergeResult: |
| 597 | """Three-way merge at file granularity, respecting ``.museattributes``. |
| 598 | |
| 599 | Standard three-way logic, augmented by per-path strategy overrides |
| 600 | declared in ``.museattributes``: |
| 601 | |
| 602 | - Both sides agree → consensus wins (including both deleted). |
| 603 | - Only one side changed → take that side. |
| 604 | - Both sides changed differently → consult ``.museattributes``: |
| 605 | |
| 606 | - ``ours`` — take left; remove from conflict list. |
| 607 | - ``theirs`` — take right; remove from conflict list. |
| 608 | - ``base`` — revert to the common ancestor; remove from conflicts. |
| 609 | - ``union`` — keep all additions from both sides; prefer left for |
| 610 | conflicting blobs; remove from conflict list. |
| 611 | - ``manual`` — force into conflict list regardless of auto resolution. |
| 612 | - ``auto`` — default three-way conflict. |
| 613 | |
| 614 | This is the fallback used by ``muse cherry-pick`` and contexts where |
| 615 | the OT merge path is not available. :meth:`merge_ops` provides |
| 616 | symbol-level conflict detection when both sides have structured deltas. |
| 617 | |
| 618 | Args: |
| 619 | base: Common ancestor snapshot. |
| 620 | left: Our branch snapshot. |
| 621 | right: Their branch snapshot. |
| 622 | repo_root: Repository root; when provided, ``.museattributes`` is |
| 623 | consulted for per-path strategy overrides. |
| 624 | |
| 625 | Returns: |
| 626 | A ``MergeResult`` with the reconciled snapshot, any file-level |
| 627 | conflicts, and ``applied_strategies`` recording which rules fired. |
| 628 | """ |
| 629 | attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else [] |
| 630 | |
| 631 | base_files = base["files"] |
| 632 | left_files = left["files"] |
| 633 | right_files = right["files"] |
| 634 | |
| 635 | merged: Manifest = dict(base_files) |
| 636 | conflicts: list[str] = [] |
| 637 | applied_strategies: AppliedStrategies = {} |
| 638 | |
| 639 | all_paths = set(base_files) | set(left_files) | set(right_files) |
| 640 | for path in sorted(all_paths): |
| 641 | b = base_files.get(path) |
| 642 | l = left_files.get(path) |
| 643 | r = right_files.get(path) |
| 644 | |
| 645 | if l == r: |
| 646 | # Both sides agree — or both deleted. |
| 647 | if l is None: |
| 648 | merged.pop(path, None) |
| 649 | else: |
| 650 | merged[path] = l |
| 651 | # Honour "manual" override even on clean paths. |
| 652 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 653 | conflicts.append(path) |
| 654 | applied_strategies[path] = "manual" |
| 655 | elif b == l: |
| 656 | # Only right changed. |
| 657 | if r is None: |
| 658 | merged.pop(path, None) |
| 659 | else: |
| 660 | merged[path] = r |
| 661 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 662 | conflicts.append(path) |
| 663 | applied_strategies[path] = "manual" |
| 664 | elif b == r: |
| 665 | # Only left changed. |
| 666 | if l is None: |
| 667 | merged.pop(path, None) |
| 668 | else: |
| 669 | merged[path] = l |
| 670 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 671 | conflicts.append(path) |
| 672 | applied_strategies[path] = "manual" |
| 673 | else: |
| 674 | # Both sides changed differently — consult attributes. |
| 675 | strategy = resolve_strategy(attrs, path) if attrs else "auto" |
| 676 | if strategy == "ours": |
| 677 | merged[path] = l or b or "" |
| 678 | applied_strategies[path] = "ours" |
| 679 | elif strategy == "theirs": |
| 680 | merged[path] = r or b or "" |
| 681 | applied_strategies[path] = "theirs" |
| 682 | elif strategy == "base": |
| 683 | if b is None: |
| 684 | merged.pop(path, None) |
| 685 | else: |
| 686 | merged[path] = b |
| 687 | applied_strategies[path] = "base" |
| 688 | elif strategy == "union": |
| 689 | # For file-level blobs, full union is not representable — |
| 690 | # prefer left and keep all additions from both branches. |
| 691 | merged[path] = l or r or b or "" |
| 692 | applied_strategies[path] = "union" |
| 693 | elif strategy == "manual": |
| 694 | conflicts.append(path) |
| 695 | merged[path] = l or r or b or "" |
| 696 | applied_strategies[path] = "manual" |
| 697 | else: |
| 698 | # "auto" — standard three-way conflict. |
| 699 | conflicts.append(path) |
| 700 | merged[path] = l or r or b or "" |
| 701 | |
| 702 | return MergeResult( |
| 703 | merged=SnapshotManifest( |
| 704 | files=merged, |
| 705 | domain=_DOMAIN_NAME, |
| 706 | directories=directories_from_manifest(merged), |
| 707 | ), |
| 708 | conflicts=conflicts, |
| 709 | applied_strategies=applied_strategies, |
| 710 | ) |
| 711 | |
| 712 | # ------------------------------------------------------------------ |
| 713 | # 4. drift |
| 714 | # ------------------------------------------------------------------ |
| 715 | |
| 716 | def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport: |
| 717 | """Report how much the working tree has drifted from the last commit. |
| 718 | |
| 719 | Called by ``muse status``. Takes a snapshot of the current live |
| 720 | state and diffs it against the committed snapshot. |
| 721 | |
| 722 | Args: |
| 723 | committed: The last committed snapshot. |
| 724 | live: Current live state (path or snapshot manifest). |
| 725 | |
| 726 | Returns: |
| 727 | A ``DriftReport`` describing what has changed since the last commit. |
| 728 | """ |
| 729 | current = self.snapshot(live) |
| 730 | delta = self.diff(committed, current) |
| 731 | |
| 732 | # Remove delete ops for files that are in .museignore but still on |
| 733 | # disk. These were previously committed (exist in HEAD) but have |
| 734 | # since been added to .museignore to stop tracking them. Surfacing |
| 735 | # them as deleted in status or diff is misleading — they are present |
| 736 | # and unchanged; the user just does not want to track them anymore. |
| 737 | # Only file-level delete ops are considered (address has no "::"). |
| 738 | if isinstance(live, pathlib.Path) and delta.get("ops"): |
| 739 | ignore_patterns = load_ignore_patterns(live) |
| 740 | filtered = [ |
| 741 | op for op in delta["ops"] |
| 742 | if not ( |
| 743 | op.get("op") == "delete" |
| 744 | and "::" not in op.get("address", "") |
| 745 | and is_ignored(op["address"], ignore_patterns) |
| 746 | and (live / op["address"]).exists() |
| 747 | ) |
| 748 | ] |
| 749 | if len(filtered) != len(delta["ops"]): |
| 750 | delta = StructuredDelta( |
| 751 | domain=delta["domain"], |
| 752 | ops=filtered, |
| 753 | summary=delta_summary(filtered), |
| 754 | ) |
| 755 | |
| 756 | return DriftReport( |
| 757 | has_drift=len(delta["ops"]) > 0, |
| 758 | summary=delta["summary"], |
| 759 | delta=delta, |
| 760 | ) |
| 761 | |
| 762 | # ------------------------------------------------------------------ |
| 763 | # 5. apply |
| 764 | # ------------------------------------------------------------------ |
| 765 | |
| 766 | def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: |
| 767 | """Apply a delta to the working tree. |
| 768 | |
| 769 | Called by ``muse checkout`` after the core engine has already |
| 770 | restored file-level objects from the object store. The code plugin |
| 771 | has no domain-specific post-processing to perform, so this is a |
| 772 | pass-through. |
| 773 | |
| 774 | Args: |
| 775 | delta: The typed operation list (unused at post-checkout time). |
| 776 | live_state: Current live state (returned unchanged). |
| 777 | |
| 778 | Returns: |
| 779 | *live_state* unchanged. |
| 780 | """ |
| 781 | return live_state |
| 782 | |
| 783 | # ------------------------------------------------------------------ |
| 784 | # 6. schema |
| 785 | # ------------------------------------------------------------------ |
| 786 | |
| 787 | def schema(self) -> DomainSchema: |
| 788 | """Declare the structural schema of the code domain. |
| 789 | |
| 790 | Returns: |
| 791 | A ``DomainSchema`` with five semantic dimensions: |
| 792 | ``structure``, ``symbols``, ``imports``, ``variables``, |
| 793 | and ``metadata``. |
| 794 | """ |
| 795 | return DomainSchema( |
| 796 | domain=_DOMAIN_NAME, |
| 797 | description=( |
| 798 | "Semantic version control for source code. " |
| 799 | "Treats code as a structured system of named symbols " |
| 800 | "(functions, classes, methods) rather than lines of text. " |
| 801 | "Two commits that only reformat a file produce no delta. " |
| 802 | "Renames and moves are detected via content-addressed " |
| 803 | "symbol identity." |
| 804 | ), |
| 805 | top_level=TreeSchema( |
| 806 | kind="tree", |
| 807 | node_type="module", |
| 808 | diff_algorithm="gumtree", |
| 809 | ), |
| 810 | dimensions=[ |
| 811 | DimensionSpec( |
| 812 | name="structure", |
| 813 | description=( |
| 814 | "Module / file tree. Tracks which files exist and " |
| 815 | "how they relate to each other." |
| 816 | ), |
| 817 | schema=TreeSchema( |
| 818 | kind="tree", |
| 819 | node_type="file", |
| 820 | diff_algorithm="gumtree", |
| 821 | ), |
| 822 | independent_merge=False, |
| 823 | ), |
| 824 | DimensionSpec( |
| 825 | name="symbols", |
| 826 | description=( |
| 827 | "AST symbol tree. Functions, classes, methods, and " |
| 828 | "variables — the primary unit of semantic change." |
| 829 | ), |
| 830 | schema=TreeSchema( |
| 831 | kind="tree", |
| 832 | node_type="symbol", |
| 833 | diff_algorithm="gumtree", |
| 834 | ), |
| 835 | independent_merge=True, |
| 836 | ), |
| 837 | DimensionSpec( |
| 838 | name="imports", |
| 839 | description=( |
| 840 | "Import set. Tracks added / removed import statements " |
| 841 | "as an unordered set — order is semantically irrelevant." |
| 842 | ), |
| 843 | schema=SetSchema( |
| 844 | kind="set", |
| 845 | element_type="import", |
| 846 | identity="by_content", |
| 847 | ), |
| 848 | independent_merge=True, |
| 849 | ), |
| 850 | DimensionSpec( |
| 851 | name="variables", |
| 852 | description=( |
| 853 | "Top-level variable and constant assignments. " |
| 854 | "Tracked as an unordered set." |
| 855 | ), |
| 856 | schema=SetSchema( |
| 857 | kind="set", |
| 858 | element_type="variable", |
| 859 | identity="by_content", |
| 860 | ), |
| 861 | independent_merge=True, |
| 862 | ), |
| 863 | DimensionSpec( |
| 864 | name="metadata", |
| 865 | description=( |
| 866 | "Non-code files: configuration, documentation, " |
| 867 | "build scripts, etc. Tracked at file granularity." |
| 868 | ), |
| 869 | schema=SetSchema( |
| 870 | kind="set", |
| 871 | element_type="file", |
| 872 | identity="by_content", |
| 873 | ), |
| 874 | independent_merge=True, |
| 875 | ), |
| 876 | ], |
| 877 | merge_mode="three_way", |
| 878 | schema_version=__version__, |
| 879 | ) |
| 880 | |
| 881 | # ------------------------------------------------------------------ |
| 882 | # StructuredMergePlugin — OT extension |
| 883 | # ------------------------------------------------------------------ |
| 884 | |
| 885 | def merge_ops( |
| 886 | self, |
| 887 | base: StateSnapshot, |
| 888 | ours_snap: StateSnapshot, |
| 889 | theirs_snap: StateSnapshot, |
| 890 | ours_ops: list[DomainOp], |
| 891 | theirs_ops: list[DomainOp], |
| 892 | *, |
| 893 | repo_root: pathlib.Path | None = None, |
| 894 | ) -> MergeResult: |
| 895 | """Operation-level three-way merge using Operational Transformation. |
| 896 | |
| 897 | Uses :func:`~muse.core.op_transform.merge_op_lists` to determine |
| 898 | which ``DomainOp`` pairs commute (auto-mergeable) and which conflict. |
| 899 | For ``PatchOp`` entries at the same file address, the engine recurses |
| 900 | into ``child_ops`` — so two agents modifying *different* functions in |
| 901 | the same file produce a file-level conflict while concurrent |
| 902 | modifications to the *same* function produce a symbol-level conflict |
| 903 | address (e.g. ``"src/utils.py::calculate_total"``). |
| 904 | |
| 905 | **Conflict propagation** — the OT check operates at symbol granularity |
| 906 | and can miss two classes of file-level conflict: |
| 907 | |
| 908 | 1. *Mixed op types*: one branch produced a ``ReplaceOp`` (no symbol |
| 909 | tree available for that file type) and the other produced a |
| 910 | ``PatchOp`` (symbol tree available). They never appear together |
| 911 | in the OT conflict-detection loops. |
| 912 | 2. *Commuting symbol changes*: two branches modify *different* symbols |
| 913 | in the same file. The individual ``DomainOp``\\s commute so OT |
| 914 | reports a clean merge, but Muse cannot reconstruct the merged file |
| 915 | blob without a text-merge pass — the merged manifest would silently |
| 916 | contain only the "ours" blob, discarding "theirs" changes. |
| 917 | |
| 918 | In both cases the file-level :meth:`merge` fallback correctly flags a |
| 919 | conflict. ``merge_ops`` propagates those flags unless the path was |
| 920 | already explicitly auto-resolved by a ``.museattributes`` strategy |
| 921 | (``ours``, ``theirs``, ``base``, or ``union``). |
| 922 | |
| 923 | Args: |
| 924 | base: Common ancestor snapshot. |
| 925 | ours_snap: Our branch's final snapshot. |
| 926 | theirs_snap: Their branch's final snapshot. |
| 927 | ours_ops: Our branch's typed operation list. |
| 928 | theirs_ops: Their branch's typed operation list. |
| 929 | repo_root: Repository root for ``.museattributes`` lookup. |
| 930 | |
| 931 | Returns: |
| 932 | A ``MergeResult`` where ``conflicts`` contains either symbol-level |
| 933 | addresses (``"src/utils.py::calculate_total"``) or bare file paths |
| 934 | (``"AGENTS.md"``) depending on the granularity at which the |
| 935 | conflict was detected. |
| 936 | """ |
| 937 | # The core OT engine's _op_key for PatchOp hashes only the file path |
| 938 | # and child_domain — not the child_ops themselves. This means two |
| 939 | # PatchOps for the same file are treated as "consensus" regardless of |
| 940 | # whether they touch the same or different symbols. We therefore |
| 941 | # implement symbol-level conflict detection directly here. |
| 942 | |
| 943 | attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else [] |
| 944 | |
| 945 | # ── Step 1: symbol-level conflict detection for PatchOps ────────── |
| 946 | ours_patches: PatchOpMap = { |
| 947 | op["address"]: op for op in ours_ops if op["op"] == "patch" |
| 948 | } |
| 949 | theirs_patches: PatchOpMap = { |
| 950 | op["address"]: op for op in theirs_ops if op["op"] == "patch" |
| 951 | } |
| 952 | |
| 953 | conflict_addresses: set[str] = set() |
| 954 | for path in ours_patches: |
| 955 | if path not in theirs_patches: |
| 956 | continue |
| 957 | for our_child in ours_patches[path]["child_ops"]: |
| 958 | for their_child in theirs_patches[path]["child_ops"]: |
| 959 | if not ops_commute(our_child, their_child): |
| 960 | conflict_addresses.add(our_child["address"]) |
| 961 | |
| 962 | # ── Step 2: coarse OT for non-PatchOp ops (file-level inserts/deletes) ── |
| 963 | non_patch_ours: list[DomainOp] = [op for op in ours_ops if op["op"] != "patch"] |
| 964 | non_patch_theirs: list[DomainOp] = [op for op in theirs_ops if op["op"] != "patch"] |
| 965 | file_result = merge_op_lists( |
| 966 | base_ops=[], |
| 967 | ours_ops=non_patch_ours, |
| 968 | theirs_ops=non_patch_theirs, |
| 969 | ) |
| 970 | for our_op, _ in file_result.conflict_ops: |
| 971 | conflict_addresses.add(our_op["address"]) |
| 972 | |
| 973 | # ── Step 3: apply .museattributes to symbol-level conflicts ────── |
| 974 | # Symbol addresses are of the form "src/utils.py::function_name". |
| 975 | # We resolve strategy against the file path portion so that a |
| 976 | # path = "src/**/*.py" / strategy = "ours" rule suppresses symbol |
| 977 | # conflicts in those files, not just file-level manifest conflicts. |
| 978 | op_applied_strategies: AppliedStrategies = {} |
| 979 | resolved_conflicts: list[str] = [] |
| 980 | if attrs: |
| 981 | for addr in sorted(conflict_addresses): |
| 982 | file_path = addr.split("::")[0] if "::" in addr else addr |
| 983 | strategy = resolve_strategy(attrs, file_path) |
| 984 | if strategy in ("ours", "theirs", "base", "union"): |
| 985 | op_applied_strategies[addr] = strategy |
| 986 | elif strategy == "manual": |
| 987 | resolved_conflicts.append(addr) |
| 988 | op_applied_strategies[addr] = "manual" |
| 989 | else: |
| 990 | resolved_conflicts.append(addr) |
| 991 | else: |
| 992 | resolved_conflicts = sorted(conflict_addresses) |
| 993 | |
| 994 | merged_ops: list[DomainOp] = list(file_result.merged_ops) + list(ours_ops) |
| 995 | |
| 996 | # Fall back to file-level merge for the manifest (carries its own |
| 997 | # applied_strategies from file-level attribute resolution). |
| 998 | fallback = self.merge(base, ours_snap, theirs_snap, repo_root=repo_root) |
| 999 | combined_strategies = {**fallback.applied_strategies, **op_applied_strategies} |
| 1000 | |
| 1001 | # ── Step 4: propagate file-level conflicts missed by the OT check ── |
| 1002 | # Extract the file path from each OT-detected conflict address so we |
| 1003 | # can avoid duplicating conflicts that are already represented at the |
| 1004 | # symbol level (e.g. "src/utils.py::my_func" covers "src/utils.py"). |
| 1005 | auto_resolved_file_paths: set[str] = { |
| 1006 | (addr.split("::")[0] if "::" in addr else addr) |
| 1007 | for addr, strat in combined_strategies.items() |
| 1008 | if strat in ("ours", "theirs", "base", "union") |
| 1009 | } |
| 1010 | ot_conflict_file_paths: set[str] = { |
| 1011 | (addr.split("::")[0] if "::" in addr else addr) |
| 1012 | for addr in resolved_conflicts |
| 1013 | } |
| 1014 | propagated_file_conflicts: list[str] = [ |
| 1015 | p for p in fallback.conflicts |
| 1016 | if p not in auto_resolved_file_paths and p not in ot_conflict_file_paths |
| 1017 | ] |
| 1018 | all_conflicts: list[str] = resolved_conflicts + sorted(propagated_file_conflicts) |
| 1019 | |
| 1020 | return MergeResult( |
| 1021 | merged=fallback.merged, |
| 1022 | conflicts=all_conflicts, |
| 1023 | applied_strategies=combined_strategies, |
| 1024 | dimension_reports=fallback.dimension_reports, |
| 1025 | op_log=merged_ops, |
| 1026 | ) |
| 1027 | |
| 1028 | |
| 1029 | # --------------------------------------------------------------------------- |
| 1030 | # Private helpers |
| 1031 | # --------------------------------------------------------------------------- |
| 1032 | |
| 1033 | |
| 1034 | def _hash_file(path: pathlib.Path) -> str: |
| 1035 | """Return the SHA-256 hex digest of *path*'s raw bytes.""" |
| 1036 | h = hashlib.sha256() |
| 1037 | with path.open("rb") as fh: |
| 1038 | for chunk in iter(lambda: fh.read(65_536), b""): |
| 1039 | h.update(chunk) |
| 1040 | return h.hexdigest() |
| 1041 | |
| 1042 | |
| 1043 | |
| 1044 | def _read_blob( |
| 1045 | repo_root: pathlib.Path, |
| 1046 | content_id: str, |
| 1047 | disk_fallback: pathlib.Path | None, |
| 1048 | ) -> bytes | None: |
| 1049 | """Read a blob from the object store; fall back to disk when not found. |
| 1050 | |
| 1051 | When ``disk_fallback`` is provided and the object store returns ``None`` |
| 1052 | (blob not yet committed — typical during ``muse diff`` on the working |
| 1053 | tree), we read the file directly from disk and verify its SHA-256 matches |
| 1054 | ``content_id`` before returning it. This guarantees we never parse stale |
| 1055 | content from a file whose hash has changed since the snapshot was taken. |
| 1056 | """ |
| 1057 | raw = read_object(repo_root, content_id) |
| 1058 | if raw is not None: |
| 1059 | return raw |
| 1060 | if disk_fallback is None or not disk_fallback.is_file(): |
| 1061 | return None |
| 1062 | try: |
| 1063 | candidate = disk_fallback.read_bytes() |
| 1064 | except OSError: |
| 1065 | return None |
| 1066 | if _hash_file(disk_fallback) == content_id: |
| 1067 | return candidate |
| 1068 | return None |
| 1069 | |
| 1070 | |
| 1071 | def _semantic_ops( |
| 1072 | base_files: Manifest, |
| 1073 | target_files: Manifest, |
| 1074 | repo_root: pathlib.Path, |
| 1075 | workdir: pathlib.Path | None = None, |
| 1076 | ) -> list[DomainOp]: |
| 1077 | """Produce symbol-level ops by reading files from the object store. |
| 1078 | |
| 1079 | When *workdir* is supplied (working-tree diffs), blobs that are not yet |
| 1080 | in the object store are read directly from disk and verified against their |
| 1081 | content hash. This enables full semantic diffing for ``muse diff`` before |
| 1082 | a commit has been made. |
| 1083 | """ |
| 1084 | base_paths = set(base_files) |
| 1085 | target_paths = set(target_files) |
| 1086 | changed_paths = ( |
| 1087 | (target_paths - base_paths) # added |
| 1088 | | (base_paths - target_paths) # removed |
| 1089 | | { # modified |
| 1090 | p for p in base_paths & target_paths |
| 1091 | if base_files[p] != target_files[p] |
| 1092 | } |
| 1093 | ) |
| 1094 | |
| 1095 | base_trees: SymbolTreeMap = {} |
| 1096 | target_trees: SymbolTreeMap = {} |
| 1097 | |
| 1098 | for path in changed_paths: |
| 1099 | if path in base_files: |
| 1100 | raw = _read_blob(repo_root, base_files[path], None) |
| 1101 | if raw is not None: |
| 1102 | base_trees[path] = _parse_with_fallback(raw, path) |
| 1103 | |
| 1104 | if path in target_files: |
| 1105 | disk_path = (workdir / path) if workdir is not None else None |
| 1106 | raw = _read_blob(repo_root, target_files[path], disk_path) |
| 1107 | if raw is not None: |
| 1108 | target_trees[path] = _parse_with_fallback(raw, path) |
| 1109 | |
| 1110 | return build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1111 | |
| 1112 | |
| 1113 | def _parse_with_fallback(source: bytes, file_path: str) -> SymbolTree: |
| 1114 | """Parse symbols from *source*, returning an empty tree on any error.""" |
| 1115 | try: |
| 1116 | return parse_symbols(source, file_path) |
| 1117 | except Exception: |
| 1118 | logger.debug("Symbol parsing failed for %s — falling back to file-level.", file_path) |
| 1119 | return {} |
| 1120 | |
| 1121 | |
| 1122 | |
| 1123 | # --------------------------------------------------------------------------- |
| 1124 | # Module-level singleton |
| 1125 | # --------------------------------------------------------------------------- |
| 1126 | |
| 1127 | #: The singleton plugin instance registered in ``muse/plugins/registry.py``. |
| 1128 | plugin = CodePlugin() |
File History
8 commits
sha256:f0cc3f3fe40f1df9a488585b5b2aef6aa4dcb0b22b5fdbf7bab7071a5b0c7f7c
feat(merge): Phase 7 — independence merge correctness + Har…
Sonnet 4.6
minor
⚠
35 days ago
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f
fix: convergent-edit phantom conflicts in ops_commute + mer…
Sonnet 4.6
minor
⚠
36 days ago
sha256:905215548c1b358bdbac94afa2d508b804f852675dc2311be67b916d79897074
fix: snapshot() carries forward committed empty dirs when s…
Sonnet 4.6
minor
⚠
49 days ago
sha256:3f46367650ccd121654f3bbe06ed3471a9007c3229fe9556d1069d64b6a2550a
refactor: directories are proper content-addressed objects …
Sonnet 4.6
patch
49 days ago
sha256:8b3bbc331871a67d637a5dfd8fa2dcdcf6c73b682bab4cd11fb534220913e7bc
fix: untracked dirs invisible to muse diff; add 30-test dir…
Sonnet 4.6
minor
⚠
49 days ago
sha256:8c872e4dffa2db45a9629956256fa1c99a3d2ff33b80c055252e58d94a0e8d1b
feat: staged directory renames shown as renamed in muse status
Sonnet 4.6
minor
⚠
49 days ago
sha256:94c593758c9f8d75fc1c8020e7d62a93305ce1478afb82d2db272bd7c1702714
feat: muse mv supports directories; fix staged_deleted dirs…
Sonnet 4.6
minor
⚠
49 days ago
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4
feat: first-class directory tracking across status, diff, r…
Sonnet 4.6
minor
⚠
49 days ago