plugin.py
python
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9
Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump…
Human
13 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.AddressedMergePlugin` 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 | - ``AddressedInsertOp`` — a symbol was added (address ``"src/utils.py::my_func"``). |
| 47 | - ``AddressedDeleteOp`` — 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 ``AddressedInsertOp`` / ``AddressedDeleteOp`` / |
| 52 | ``ReplaceOp`` at the file level. |
| 53 | |
| 54 | Merge Semantics |
| 55 | --------------- |
| 56 | The plugin implements :class:`~muse.domain.AddressedMergePlugin` — a Map CRDT |
| 57 | with address-keyed operations and Harmony for conflict resolution: |
| 58 | |
| 59 | - Agent A modifies ``foo()`` and Agent B modifies ``bar()`` in the same |
| 60 | file → **auto-merge** (ops on different addresses 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 | import logging |
| 85 | import os |
| 86 | import pathlib |
| 87 | import stat as _stat |
| 88 | |
| 89 | from muse._version import __version__ |
| 90 | from muse.core.merge_debug import merge_debug_log |
| 91 | from muse.core.attributes import load_attributes, resolve_strategy |
| 92 | from muse.core.diff_algorithms import snapshot_diff |
| 93 | from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns |
| 94 | from muse.core.snapshot import ( |
| 95 | _BUILTIN_SECRET_PATTERNS, |
| 96 | detect_directory_renames, |
| 97 | directories_from_manifest, |
| 98 | load_ignore_patterns, |
| 99 | ) |
| 100 | from muse.core.cohen_transform import compute_regions |
| 101 | from muse.core.object_store import read_object, write_object |
| 102 | from muse.core.types import blob_id, hash_file |
| 103 | from muse.core.op_merge 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 | AddressedDeleteOp, |
| 113 | AddressedInsertOp, |
| 114 | ConflictRecord, |
| 115 | RenameOp, |
| 116 | DomainOp, |
| 117 | DirStatus, |
| 118 | DriftReport, |
| 119 | LiveState, |
| 120 | MergeResult, |
| 121 | PatchOp, |
| 122 | SnapshotManifest, |
| 123 | StagedEntry, |
| 124 | StageStatus, |
| 125 | StateDelta, |
| 126 | StateSnapshot, |
| 127 | StructuredDelta, |
| 128 | ) |
| 129 | from muse.plugins.code.stage import ( |
| 130 | EMPTY_DIR_OID, |
| 131 | clear_stage, |
| 132 | read_stage, |
| 133 | read_stage_dir_renames, |
| 134 | stage_path, |
| 135 | write_stage, |
| 136 | ) |
| 137 | from muse.plugins.code.ast_parser import ( |
| 138 | SymbolTree, |
| 139 | parse_symbols, |
| 140 | ) |
| 141 | from muse.plugins.code.symbol_diff import ( |
| 142 | build_diff_ops, |
| 143 | delta_summary, |
| 144 | ) |
| 145 | from muse.core.types import Manifest |
| 146 | |
| 147 | logger = logging.getLogger(__name__) |
| 148 | |
| 149 | type FileStrMap = dict[str, str] # file_path → hash/content (generic manifest) |
| 150 | type SymbolTreeMap = dict[str, SymbolTree] # file_path → symbol tree |
| 151 | type PatchOpMap = dict[str, PatchOp] # address → patch op |
| 152 | type AppliedStrategies = dict[str, str] # file_path → strategy name |
| 153 | type StagedEntryMap = dict[str, StagedEntry] # file_path → staged entry |
| 154 | |
| 155 | _DOMAIN_NAME = "code" |
| 156 | |
| 157 | # Directories that are never versioned regardless of .museignore. |
| 158 | # These are implicit ignores that apply to all code repositories. |
| 159 | _ALWAYS_IGNORE_DIRS: frozenset[str] = frozenset({ |
| 160 | ".git", |
| 161 | ".muse", |
| 162 | "__pycache__", |
| 163 | ".mypy_cache", |
| 164 | ".pytest_cache", |
| 165 | ".ruff_cache", |
| 166 | "node_modules", |
| 167 | ".venv", |
| 168 | "venv", |
| 169 | ".tox", |
| 170 | ".nox", |
| 171 | ".coverage", |
| 172 | "htmlcov", |
| 173 | "dist", |
| 174 | "build", |
| 175 | ".eggs", |
| 176 | ".DS_Store", |
| 177 | }) |
| 178 | |
| 179 | def _head_manifest_for(root: pathlib.Path) -> Manifest: |
| 180 | """Return the manifest from the current HEAD commit (empty dict if none). |
| 181 | |
| 182 | Resolves the branch through ``get_head_commit_id`` (the store |
| 183 | abstraction) rather than reading the ref file directly. |
| 184 | |
| 185 | Used by ``snapshot()`` (to build the staged manifest) and ``stage_status()`` |
| 186 | (to compute the unstaged diff). Kept outside the class so it can be called |
| 187 | from module-level helpers without a plugin instance. |
| 188 | """ |
| 189 | from muse.core.refs import ( |
| 190 | get_head_commit_id, |
| 191 | read_current_branch, |
| 192 | ) |
| 193 | from muse.core.commits import read_commit |
| 194 | from muse.core.snapshots import read_snapshot |
| 195 | |
| 196 | try: |
| 197 | branch = read_current_branch(root) |
| 198 | commit_id = get_head_commit_id(root, branch) |
| 199 | if not commit_id: |
| 200 | return {} |
| 201 | commit = read_commit(root, commit_id) |
| 202 | if commit is None: |
| 203 | return {} |
| 204 | snap = read_snapshot(root, commit.snapshot_id) |
| 205 | return dict(snap.manifest) if snap else {} |
| 206 | except Exception: |
| 207 | return {} |
| 208 | |
| 209 | |
| 210 | def _head_snapshot_dirs_for(root: pathlib.Path) -> list[str]: |
| 211 | """Return the ``directories`` list from the current HEAD snapshot. |
| 212 | |
| 213 | Used by ``stage_status`` to detect explicitly-committed empty directories. |
| 214 | Returns an empty list for repositories with no commits or a missing snapshot. |
| 215 | """ |
| 216 | from muse.core.refs import ( |
| 217 | get_head_commit_id, |
| 218 | read_current_branch, |
| 219 | ) |
| 220 | from muse.core.commits import read_commit |
| 221 | from muse.core.snapshots import read_snapshot as _read_snapshot |
| 222 | |
| 223 | try: |
| 224 | branch = read_current_branch(root) |
| 225 | commit_id = get_head_commit_id(root, branch) |
| 226 | if not commit_id: |
| 227 | return [] |
| 228 | commit = read_commit(root, commit_id) |
| 229 | if commit is None: |
| 230 | return [] |
| 231 | snap = _read_snapshot(root, commit.snapshot_id) |
| 232 | return list(snap.directories) if snap else [] |
| 233 | except Exception: |
| 234 | return [] |
| 235 | |
| 236 | |
| 237 | # Object ID for tracked empty directories — sha256 of zero bytes. |
| 238 | # All empty dirs share this one object; it is written to the store on staging. |
| 239 | _DIR_SENTINEL = EMPTY_DIR_OID |
| 240 | |
| 241 | class CodePlugin: |
| 242 | """Muse domain plugin for software source code repositories. |
| 243 | |
| 244 | Implements all six core protocol methods plus the optional |
| 245 | :class:`~muse.domain.AddressedMergePlugin` address-keyed map merge extension |
| 246 | and the :class:`~muse.domain.StagePlugin` selective-commit extension. The |
| 247 | plugin does not implement :class:`~muse.domain.CRDTPlugin` — source code is |
| 248 | human-authored and benefits from explicit conflict resolution (via Harmony) |
| 249 | rather than automatic convergence. |
| 250 | |
| 251 | The plugin is stateless. The module-level singleton :data:`plugin` is |
| 252 | the standard entry point. |
| 253 | """ |
| 254 | |
| 255 | # ------------------------------------------------------------------ |
| 256 | # 1. snapshot |
| 257 | # ------------------------------------------------------------------ |
| 258 | |
| 259 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 260 | """Capture the current working tree as a snapshot dict. |
| 261 | |
| 262 | Walks all regular files under *live_state*, hashing each one with |
| 263 | SHA-256 (raw bytes). Honours ``.museignore`` and always ignores |
| 264 | known tool-generated directories (``__pycache__``, ``.git``, etc.). |
| 265 | |
| 266 | Uses ``os.walk`` with in-place ``dirnames`` pruning so that |
| 267 | always-ignored and hidden directories (e.g. ``.venv/``, ``node_modules/``, |
| 268 | ``.muse/``) are never descended into. The ``StatCache`` is consulted |
| 269 | before hashing so that unchanged files are not re-read from disk. |
| 270 | |
| 271 | Args: |
| 272 | live_state: A ``pathlib.Path`` pointing to the repository root, or an |
| 273 | existing ``SnapshotManifest`` dict (returned as-is). |
| 274 | |
| 275 | Returns: |
| 276 | A ``SnapshotManifest`` mapping workspace-relative POSIX paths to |
| 277 | their SHA-256 raw-bytes digests. |
| 278 | """ |
| 279 | if not isinstance(live_state, pathlib.Path): |
| 280 | return live_state |
| 281 | |
| 282 | workdir = live_state |
| 283 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME) |
| 284 | cache = load_cache(workdir) |
| 285 | files: Manifest = {} |
| 286 | musekeep_dirs: set[str] = set() |
| 287 | root_str = str(workdir) |
| 288 | prefix_len = len(root_str) + 1 |
| 289 | |
| 290 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 291 | rel_dir = "" |
| 292 | if dirpath != root_str: |
| 293 | rel_dir = dirpath[prefix_len:] |
| 294 | if os.sep != "/": |
| 295 | rel_dir = rel_dir.replace(os.sep, "/") |
| 296 | |
| 297 | # Prune subdirectories: skip _ALWAYS_IGNORE_DIRS, directories whose |
| 298 | # contents are entirely ignored by .museignore, and nested muse |
| 299 | # repos. Nested repos are independent version-controlled units — |
| 300 | # their files belong to their own snapshot, not the parent repo's. |
| 301 | # Using a sentinel file path (e.g. ".hypothesis/_") lets is_ignored |
| 302 | # detect both directory patterns (.hypothesis/) and glob patterns |
| 303 | # (.hypothesis/**) without descending into the directory first. |
| 304 | dirnames[:] = sorted( |
| 305 | d for d in dirnames |
| 306 | if d not in _ALWAYS_IGNORE_DIRS |
| 307 | and not is_ignored( |
| 308 | f"{rel_dir}/{d}/_" if rel_dir else f"{d}/_", patterns |
| 309 | ) |
| 310 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 311 | ) |
| 312 | |
| 313 | for fname in sorted(filenames): |
| 314 | abs_str = os.path.join(dirpath, fname) |
| 315 | try: |
| 316 | st = os.lstat(abs_str) |
| 317 | except OSError: |
| 318 | continue |
| 319 | if not _stat.S_ISREG(st.st_mode): |
| 320 | continue |
| 321 | rel = abs_str[prefix_len:] |
| 322 | if os.sep != "/": |
| 323 | rel = rel.replace(os.sep, "/") |
| 324 | if is_ignored(rel, patterns): |
| 325 | continue |
| 326 | files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino) |
| 327 | if fname == ".musekeep" and rel_dir: |
| 328 | musekeep_dirs.add(rel_dir) |
| 329 | |
| 330 | cache.prune(set(files)) |
| 331 | cache.save() |
| 332 | |
| 333 | # If a stage index is active, filter the manifest so that only staged |
| 334 | # files (using their staged object IDs) and previously-committed files |
| 335 | # (at their committed state) are included. This is the core of the |
| 336 | # selective-commit model: unstaged working-tree changes are invisible |
| 337 | # to ``muse commit``. |
| 338 | stage = read_stage(workdir) |
| 339 | if stage: |
| 340 | committed = _head_manifest_for(workdir) |
| 341 | staged_manifest: Manifest = {} |
| 342 | # Strip any VCS-internal paths that may have leaked into a prior snapshot. |
| 343 | staged_manifest.update( |
| 344 | {k: v for k, v in committed.items() if not k.startswith(".muse/")} |
| 345 | ) |
| 346 | for rel_path, entry in stage.items(): |
| 347 | if rel_path.startswith(".muse/"): |
| 348 | continue # never allow VCS internals in snapshot |
| 349 | if entry["object_id"] == _DIR_SENTINEL: |
| 350 | continue # sentinel entries go into directories, not files |
| 351 | if entry["mode"] == "D": |
| 352 | staged_manifest.pop(rel_path, None) |
| 353 | else: |
| 354 | staged_manifest[rel_path] = entry["object_id"] |
| 355 | # Include explicitly-staged empty directories (sentinel entries). |
| 356 | staged_empty_dirs: set[str] = { |
| 357 | rel for rel, entry in stage.items() |
| 358 | if entry["object_id"] == _DIR_SENTINEL and entry["mode"] == "A" |
| 359 | } |
| 360 | staged_deleted_dirs: set[str] = { |
| 361 | rel for rel, entry in stage.items() |
| 362 | if entry["object_id"] == _DIR_SENTINEL and entry["mode"] == "D" |
| 363 | } |
| 364 | # Carry forward committed empty dirs from HEAD, minus any staged |
| 365 | # for deletion. Without this, committing a second directory drops |
| 366 | # the first from snapshot.directories. |
| 367 | committed_empty_dirs = set(_head_snapshot_dirs_for(workdir)) |
| 368 | staged_dirs = sorted( |
| 369 | set(directories_from_manifest(staged_manifest)) |
| 370 | | staged_empty_dirs |
| 371 | | (committed_empty_dirs - staged_deleted_dirs) |
| 372 | ) |
| 373 | return SnapshotManifest(files=staged_manifest, domain=_DOMAIN_NAME, directories=staged_dirs) |
| 374 | |
| 375 | # Only include directories that contain tracked files. Empty |
| 376 | # directories are tracked explicitly via staging (sentinel entries). |
| 377 | dirs = sorted(directories_from_manifest(files)) |
| 378 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=dirs) |
| 379 | |
| 380 | def workdir_snapshot(self, root: pathlib.Path) -> SnapshotManifest: |
| 381 | """Capture the raw working tree, bypassing any active stage. |
| 382 | |
| 383 | Identical to :meth:`snapshot` but skips the stage-overlay logic, |
| 384 | so every on-disk file is reflected at its current content hash. |
| 385 | Used by ``muse diff --working``. |
| 386 | """ |
| 387 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(root), _DOMAIN_NAME) |
| 388 | cache = load_cache(root) |
| 389 | files: Manifest = {} |
| 390 | root_str = str(root) |
| 391 | prefix_len = len(root_str) + 1 |
| 392 | |
| 393 | on_disk_dirs: set[str] = set() |
| 394 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 395 | rel_dir = "" |
| 396 | if dirpath != root_str: |
| 397 | rel_dir = dirpath[prefix_len:] |
| 398 | if os.sep != "/": |
| 399 | rel_dir = rel_dir.replace(os.sep, "/") |
| 400 | dirnames[:] = sorted( |
| 401 | d for d in dirnames |
| 402 | if d not in _ALWAYS_IGNORE_DIRS |
| 403 | and not is_ignored( |
| 404 | f"{rel_dir}/{d}/_" if rel_dir else f"{d}/_", patterns |
| 405 | ) |
| 406 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 407 | ) |
| 408 | # Track every non-root directory that exists on disk so that |
| 409 | # committed empty dirs still present in the working tree are not |
| 410 | # falsely reported as deleted by the diff engine. |
| 411 | if rel_dir: |
| 412 | on_disk_dirs.add(rel_dir) |
| 413 | for fname in sorted(filenames): |
| 414 | abs_str = os.path.join(dirpath, fname) |
| 415 | try: |
| 416 | st = os.lstat(abs_str) |
| 417 | except OSError: |
| 418 | continue |
| 419 | if not _stat.S_ISREG(st.st_mode): |
| 420 | continue |
| 421 | rel = abs_str[prefix_len:] |
| 422 | if os.sep != "/": |
| 423 | rel = rel.replace(os.sep, "/") |
| 424 | if is_ignored(rel, patterns): |
| 425 | continue |
| 426 | files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino) |
| 427 | |
| 428 | # Only include dirs that are tracked (committed or staged) — untracked |
| 429 | # dirs must not appear in the working tree snapshot so that muse diff |
| 430 | # doesn't report them as changes (same as git: untracked is invisible to diff). |
| 431 | committed_dirs = set(_head_snapshot_dirs_for(root)) |
| 432 | staged = read_stage(root) |
| 433 | staged_dirs: set[str] = { |
| 434 | p for p, e in staged.items() |
| 435 | if e.get("object_id") == _DIR_SENTINEL |
| 436 | } |
| 437 | tracked_on_disk_dirs = on_disk_dirs & (committed_dirs | staged_dirs) |
| 438 | wdirs = sorted(set(directories_from_manifest(files)) | tracked_on_disk_dirs) |
| 439 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=wdirs) |
| 440 | |
| 441 | # ------------------------------------------------------------------ |
| 442 | # StagePlugin implementation |
| 443 | # ------------------------------------------------------------------ |
| 444 | |
| 445 | def stage_index_path(self, root: pathlib.Path) -> pathlib.Path: |
| 446 | """Return the absolute path of ``.muse/code/stage.json``.""" |
| 447 | return stage_path(root) |
| 448 | |
| 449 | def read_stage(self, root: pathlib.Path) -> StagedEntryMap: |
| 450 | """Read the code-domain stage index.""" |
| 451 | return read_stage(root) |
| 452 | |
| 453 | def write_stage( |
| 454 | self, root: pathlib.Path, entries: StagedEntryMap |
| 455 | ) -> None: |
| 456 | """Persist *entries* as the code-domain stage index.""" |
| 457 | write_stage(root, entries) |
| 458 | |
| 459 | def clear_stage(self, root: pathlib.Path) -> None: |
| 460 | """Remove the code-domain stage index.""" |
| 461 | clear_stage(root) |
| 462 | |
| 463 | def stage_status(self, root: pathlib.Path) -> StageStatus: |
| 464 | """Return a three-bucket view of the working tree vs the stage. |
| 465 | |
| 466 | Compares: |
| 467 | |
| 468 | 1. The current stage against HEAD → **staged** bucket. |
| 469 | 2. Working tree files against their HEAD/stage state → **unstaged**. |
| 470 | 3. Files present on disk but neither tracked nor staged → **untracked**. |
| 471 | """ |
| 472 | stage = read_stage(root) |
| 473 | committed = _head_manifest_for(root) |
| 474 | |
| 475 | # Staged bucket: all stage entries (files AND empty-dir sentinels). |
| 476 | staged: StagedEntryMap = dict(stage) |
| 477 | |
| 478 | # Separate staged empty-dir entries (sentinel object_id == _DIR_SENTINEL) |
| 479 | # from file entries — they must not participate in file-level diff logic. |
| 480 | staged_added_dirs: set[str] = { |
| 481 | p for p, e in stage.items() |
| 482 | if e["object_id"] == _DIR_SENTINEL and e["mode"] == "A" |
| 483 | } |
| 484 | staged_deleted_dirs: set[str] = { |
| 485 | p for p, e in stage.items() |
| 486 | if e["object_id"] == _DIR_SENTINEL and e["mode"] == "D" |
| 487 | } |
| 488 | # Exclude dir sentinels from the file-level staged bucket so the |
| 489 | # staged/unstaged file passes below never trip on them. |
| 490 | staged_files_only: StagedEntryMap = { |
| 491 | p: e for p, e in stage.items() if e["object_id"] != _DIR_SENTINEL |
| 492 | } |
| 493 | |
| 494 | # Build the full working-tree manifest (reuse snapshot logic). |
| 495 | patterns = _BUILTIN_SECRET_PATTERNS + resolve_patterns(load_ignore_config(root), _DOMAIN_NAME) |
| 496 | cache = load_cache(root) |
| 497 | workdir_files: Manifest = {} |
| 498 | on_disk_dirs: set[str] = set() |
| 499 | root_str = str(root) |
| 500 | prefix_len = len(root_str) + 1 |
| 501 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 502 | # Compute cur_rel first so it can be used in dirnames filtering. |
| 503 | if dirpath != root_str: |
| 504 | cur_rel = dirpath[prefix_len:] |
| 505 | if os.sep != "/": |
| 506 | cur_rel = cur_rel.replace(os.sep, "/") |
| 507 | on_disk_dirs.add(cur_rel) |
| 508 | else: |
| 509 | cur_rel = "" |
| 510 | # Match the same pruning as the snapshot walk: skip _ALWAYS_IGNORE_DIRS, |
| 511 | # is_ignored directories, and nested Muse repos (dirs containing .muse/). |
| 512 | dirnames[:] = sorted( |
| 513 | d for d in dirnames |
| 514 | if d not in _ALWAYS_IGNORE_DIRS |
| 515 | and not is_ignored( |
| 516 | f"{cur_rel}/{d}/_" if cur_rel else f"{d}/_", patterns |
| 517 | ) |
| 518 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 519 | ) |
| 520 | for fname in sorted(filenames): |
| 521 | abs_str = os.path.join(dirpath, fname) |
| 522 | try: |
| 523 | st = os.lstat(abs_str) |
| 524 | except OSError: |
| 525 | continue |
| 526 | if not _stat.S_ISREG(st.st_mode): |
| 527 | continue |
| 528 | rel = abs_str[prefix_len:] |
| 529 | if os.sep != "/": |
| 530 | rel = rel.replace(os.sep, "/") |
| 531 | if is_ignored(rel, patterns): |
| 532 | continue |
| 533 | workdir_files[rel] = cache.get_cached( |
| 534 | rel, abs_str, st.st_mtime, st.st_size, st.st_ino |
| 535 | ) |
| 536 | |
| 537 | # Unstaged: files whose working-tree content diverges from what is staged |
| 538 | # (or from HEAD for untracked-by-stage files). |
| 539 | # |
| 540 | # Two passes: |
| 541 | # 1. Staged files: compare working tree against the staged object. |
| 542 | # A file modified after staging must appear in the unstaged bucket. |
| 543 | # 2. Committed files not in the stage: compare against HEAD. |
| 544 | # Dir sentinels are excluded from both passes. |
| 545 | unstaged: Manifest = {} |
| 546 | |
| 547 | for rel_path, staged_entry in staged_files_only.items(): |
| 548 | if staged_entry["mode"] == "D": |
| 549 | # Staged for deletion — if the file reappeared on disk, flag it. |
| 550 | if rel_path in workdir_files: |
| 551 | unstaged[rel_path] = "modified" |
| 552 | continue |
| 553 | staged_oid = staged_entry["object_id"] |
| 554 | current_id = workdir_files.get(rel_path) |
| 555 | if current_id is None: |
| 556 | # Staged but deleted from disk since staging. |
| 557 | unstaged[rel_path] = "deleted" |
| 558 | elif current_id != staged_oid: |
| 559 | # Modified on disk after staging — not yet re-staged. |
| 560 | unstaged[rel_path] = "modified" |
| 561 | |
| 562 | for rel_path, committed_id in committed.items(): |
| 563 | if rel_path in staged_files_only: |
| 564 | continue # already covered in the stage pass above |
| 565 | current_id = workdir_files.get(rel_path) |
| 566 | if current_id is None: |
| 567 | unstaged[rel_path] = "deleted" |
| 568 | elif current_id != committed_id: |
| 569 | unstaged[rel_path] = "modified" |
| 570 | |
| 571 | # Untracked: on disk, not committed, not staged (files only). |
| 572 | all_known_files = set(committed) | set(staged_files_only) |
| 573 | untracked_set: set[str] = { |
| 574 | rel_path for rel_path in workdir_files if rel_path not in all_known_files |
| 575 | } |
| 576 | |
| 577 | # Working-tree rename detection: a committed file deleted from disk |
| 578 | # whose content_id matches an untracked file's content_id is a rename, |
| 579 | # not a delete + new untracked file. Only unstaged deletes qualify — |
| 580 | # files explicitly staged for deletion represent deliberate user action. |
| 581 | renamed: dict[str, str] = {} |
| 582 | unstaged_deleted = [p for p, lbl in unstaged.items() if lbl == "deleted"] |
| 583 | if unstaged_deleted and untracked_set: |
| 584 | # Build content_id → sorted list of untracked paths for O(1) lookup. |
| 585 | untracked_by_cid: dict[str, list[str]] = {} |
| 586 | for ut_path in sorted(untracked_set): |
| 587 | cid = workdir_files[ut_path] |
| 588 | untracked_by_cid.setdefault(cid, []).append(ut_path) |
| 589 | |
| 590 | for old_path in sorted(unstaged_deleted): |
| 591 | old_cid = committed.get(old_path) |
| 592 | if old_cid is None: |
| 593 | continue |
| 594 | candidates = untracked_by_cid.get(old_cid) |
| 595 | if not candidates: |
| 596 | continue |
| 597 | new_path = candidates.pop(0) |
| 598 | if not candidates: |
| 599 | del untracked_by_cid[old_cid] |
| 600 | renamed[old_path] = new_path |
| 601 | del unstaged[old_path] |
| 602 | untracked_set.discard(new_path) |
| 603 | |
| 604 | untracked: list[str] = sorted(untracked_set) |
| 605 | |
| 606 | # Directory tracking: compare on-disk dirs against committed dirs. |
| 607 | # Only explicitly-empty dirs (not derivable from file paths) are surfaced. |
| 608 | # Dirs that contain tracked files are implicitly covered by their files. |
| 609 | committed_snap_dirs: list[str] = _head_snapshot_dirs_for(root) |
| 610 | # Dirs derivable from committed file paths — implicitly tracked. |
| 611 | dirs_with_committed_files: set[str] = set() |
| 612 | for f in committed: |
| 613 | parts = f.split("/") |
| 614 | for i in range(1, len(parts)): |
| 615 | dirs_with_committed_files.add("/".join(parts[:i])) |
| 616 | # Explicitly-committed empty dirs = snapshot dirs minus implicit dirs. |
| 617 | committed_empty_dirs: set[str] = set(committed_snap_dirs) - dirs_with_committed_files |
| 618 | |
| 619 | # Dirs with files in the current working tree (not empty). |
| 620 | dirs_with_workdir_files: set[str] = set() |
| 621 | for f in workdir_files: |
| 622 | parts = f.split("/") |
| 623 | for i in range(1, len(parts)): |
| 624 | dirs_with_workdir_files.add("/".join(parts[:i])) |
| 625 | # Empty dirs on disk = all dirs seen during walk minus those with files. |
| 626 | empty_on_disk_dirs: set[str] = on_disk_dirs - dirs_with_workdir_files |
| 627 | |
| 628 | # Directory rename detection: read explicit renames recorded by muse mv. |
| 629 | # Only renames where the old path is in staged_deleted_dirs AND the new |
| 630 | # path is in staged_added_dirs are valid (guards against stale entries). |
| 631 | raw_dir_renames = read_stage_dir_renames(root) |
| 632 | valid_dir_renames: dict[str, str] = { |
| 633 | old: new for old, new in raw_dir_renames.items() |
| 634 | if old in staged_deleted_dirs and new in staged_added_dirs |
| 635 | } |
| 636 | renamed_old_dirs = set(valid_dir_renames.keys()) |
| 637 | renamed_new_dirs = set(valid_dir_renames.values()) |
| 638 | |
| 639 | # Untracked dirs: empty on disk, not in committed, not staged. |
| 640 | dir_added = sorted( |
| 641 | empty_on_disk_dirs - committed_empty_dirs - staged_added_dirs |
| 642 | ) |
| 643 | # Deleted dirs: committed empty dirs no longer on disk and not already |
| 644 | # staged for deletion (e.g. via muse mv or muse rm). |
| 645 | dir_deleted = sorted(committed_empty_dirs - on_disk_dirs - staged_deleted_dirs) |
| 646 | |
| 647 | directories = DirStatus( |
| 648 | added=dir_added, |
| 649 | deleted=dir_deleted, |
| 650 | staged_added=sorted(staged_added_dirs - committed_empty_dirs - renamed_new_dirs), |
| 651 | staged_deleted=sorted(staged_deleted_dirs - renamed_old_dirs), |
| 652 | staged_renamed=valid_dir_renames, |
| 653 | ) |
| 654 | |
| 655 | return StageStatus( |
| 656 | staged=staged, |
| 657 | unstaged=unstaged, |
| 658 | untracked=untracked, |
| 659 | renamed=renamed, |
| 660 | directories=directories, |
| 661 | ) |
| 662 | |
| 663 | # ------------------------------------------------------------------ |
| 664 | # 2. diff |
| 665 | # ------------------------------------------------------------------ |
| 666 | |
| 667 | def diff( |
| 668 | self, |
| 669 | base: StateSnapshot, |
| 670 | target: StateSnapshot, |
| 671 | *, |
| 672 | repo_root: pathlib.Path | None = None, |
| 673 | ) -> StateDelta: |
| 674 | """Compute the structured delta between two snapshots. |
| 675 | |
| 676 | Without ``repo_root`` |
| 677 | Produces coarse file-level ops (``InsertOp`` / ``DeleteOp`` / |
| 678 | ``ReplaceOp``). Used by ``muse checkout`` which only needs file |
| 679 | paths. |
| 680 | |
| 681 | With ``repo_root`` |
| 682 | Reads source bytes from the object store, parses AST for |
| 683 | supported languages (Python), and produces ``PatchOp`` entries |
| 684 | with symbol-level ``child_ops``. Used by ``muse commit`` (to |
| 685 | store the structured delta) and ``muse read`` / ``muse diff``. |
| 686 | |
| 687 | Args: |
| 688 | base: Base snapshot (older state). |
| 689 | target: Target snapshot (newer state). |
| 690 | repo_root: Repository root for object-store access and symbol |
| 691 | extraction. ``None`` → file-level ops only. |
| 692 | |
| 693 | Returns: |
| 694 | A ``StructuredDelta`` with ``domain="code"``. |
| 695 | """ |
| 696 | base_files = base["files"] |
| 697 | target_files = target["files"] |
| 698 | base_dirs = list(base.get("directories") or []) |
| 699 | target_dirs = list(target.get("directories") or []) |
| 700 | |
| 701 | # ── Directory rename detection ──────────────────────────────────────── |
| 702 | # Detect which added/deleted directories are actually renames and |
| 703 | # suppress the corresponding file-level insert/delete ops so the delta |
| 704 | # is clean: one RenameOp instead of N inserts + N deletes. |
| 705 | added_dirs = set(target_dirs) - set(base_dirs) |
| 706 | deleted_dirs = set(base_dirs) - set(target_dirs) |
| 707 | dir_renames = detect_directory_renames( |
| 708 | deleted_dirs, added_dirs, base_files, target_files |
| 709 | ) |
| 710 | |
| 711 | # Build the set of file paths covered by directory renames so we can |
| 712 | # filter them out of the file-level diff inputs. |
| 713 | covered: set[str] = set() |
| 714 | for old_dir, new_dir in dir_renames: |
| 715 | old_prefix = f"{old_dir}/" |
| 716 | new_prefix = f"{new_dir}/" |
| 717 | covered |= {p for p in base_files if p.startswith(old_prefix)} |
| 718 | covered |= {p for p in target_files if p.startswith(new_prefix)} |
| 719 | |
| 720 | renamed_old = {old for old, _ in dir_renames} |
| 721 | renamed_new = {new for _, new in dir_renames} |
| 722 | |
| 723 | # Build directory-level ops. |
| 724 | dir_ops: list[DomainOp] = [] |
| 725 | for old_dir, new_dir in dir_renames: |
| 726 | dir_ops.append( |
| 727 | RenameOp( |
| 728 | op="rename", |
| 729 | address=new_dir + "/", |
| 730 | from_address=old_dir + "/", |
| 731 | ) |
| 732 | ) |
| 733 | for d in sorted(added_dirs - renamed_new): |
| 734 | dir_ops.append(AddressedInsertOp(op="insert", address=d + "/", content_id="", content_summary=f"directory: {d}/")) |
| 735 | for d in sorted(deleted_dirs - renamed_old): |
| 736 | dir_ops.append(AddressedDeleteOp(op="delete", address=d + "/", content_id="", content_summary=f"directory: {d}/")) |
| 737 | |
| 738 | # ── File-level diff (excluding covered paths) ───────────────────────── |
| 739 | if covered: |
| 740 | filtered_base = SnapshotManifest( |
| 741 | files={k: v for k, v in base_files.items() if k not in covered}, |
| 742 | domain=base["domain"], |
| 743 | directories=[], |
| 744 | ) |
| 745 | filtered_target = SnapshotManifest( |
| 746 | files={k: v for k, v in target_files.items() if k not in covered}, |
| 747 | domain=target["domain"], |
| 748 | directories=[], |
| 749 | ) |
| 750 | else: |
| 751 | filtered_base = base |
| 752 | filtered_target = target |
| 753 | |
| 754 | if repo_root is None: |
| 755 | file_delta = snapshot_diff(self.schema(), filtered_base, filtered_target) |
| 756 | file_ops: list[DomainOp] = list(file_delta.get("ops", [])) |
| 757 | else: |
| 758 | file_ops = _semantic_ops( |
| 759 | filtered_base["files"], filtered_target["files"], |
| 760 | repo_root, workdir=repo_root, |
| 761 | ) |
| 762 | |
| 763 | all_ops = dir_ops + file_ops |
| 764 | summary = delta_summary(all_ops) |
| 765 | return StructuredDelta(domain=_DOMAIN_NAME, ops=all_ops, summary=summary) |
| 766 | |
| 767 | # ------------------------------------------------------------------ |
| 768 | # 3. merge |
| 769 | # ------------------------------------------------------------------ |
| 770 | |
| 771 | def merge( |
| 772 | self, |
| 773 | base: StateSnapshot, |
| 774 | left: StateSnapshot, |
| 775 | right: StateSnapshot, |
| 776 | *, |
| 777 | repo_root: pathlib.Path | None = None, |
| 778 | ) -> MergeResult: |
| 779 | """Three-way merge at file granularity, respecting ``.museattributes``. |
| 780 | |
| 781 | Standard three-way logic, augmented by per-path strategy overrides |
| 782 | declared in ``.museattributes``: |
| 783 | |
| 784 | - Both sides agree → consensus wins (including both deleted). |
| 785 | - Only one side changed → take that side. |
| 786 | - Both sides changed differently → consult ``.museattributes``: |
| 787 | |
| 788 | - ``ours`` — take left; remove from conflict list. |
| 789 | - ``theirs`` — take right; remove from conflict list. |
| 790 | - ``base`` — revert to the common ancestor; remove from conflicts. |
| 791 | - ``union`` — keep all additions from both sides; prefer left for |
| 792 | conflicting blobs; remove from conflict list. |
| 793 | - ``manual`` — force into conflict list regardless of auto resolution. |
| 794 | - ``auto`` — default three-way conflict. |
| 795 | |
| 796 | This is the fallback used by ``muse cherry-pick`` and contexts where |
| 797 | structured deltas are unavailable. :meth:`merge_ops` provides |
| 798 | symbol-level conflict detection when both sides have structured deltas. |
| 799 | |
| 800 | Args: |
| 801 | base: Common ancestor snapshot. |
| 802 | left: Our branch snapshot. |
| 803 | right: Their branch snapshot. |
| 804 | repo_root: Repository root; when provided, ``.museattributes`` is |
| 805 | consulted for per-path strategy overrides. |
| 806 | |
| 807 | Returns: |
| 808 | A ``MergeResult`` with the reconciled snapshot, any file-level |
| 809 | conflicts, and ``applied_strategies`` recording which rules fired. |
| 810 | """ |
| 811 | attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else [] |
| 812 | |
| 813 | base_files = base["files"] |
| 814 | left_files = left["files"] |
| 815 | right_files = right["files"] |
| 816 | |
| 817 | merged: Manifest = dict(base_files) |
| 818 | conflicts: list[str] = [] |
| 819 | conflict_records: list[ConflictRecord] = [] |
| 820 | applied_strategies: AppliedStrategies = {} |
| 821 | |
| 822 | all_paths = set(base_files) | set(left_files) | set(right_files) |
| 823 | merge_debug_log("plugin.merge.enter", { |
| 824 | "all_paths_count": len(all_paths), |
| 825 | "base_file_count": len(base_files), |
| 826 | "left_file_count": len(left_files), |
| 827 | "right_file_count": len(right_files), |
| 828 | # Log paths where l==r but both changed from base (convergent edits — should NOT conflict) |
| 829 | "convergent_same": [ |
| 830 | p for p in all_paths |
| 831 | if base_files.get(p) != left_files.get(p) |
| 832 | and left_files.get(p) == right_files.get(p) |
| 833 | ], |
| 834 | # Log paths where l != r (potential conflicts) |
| 835 | "divergent_paths": [ |
| 836 | p for p in all_paths |
| 837 | if left_files.get(p) != right_files.get(p) |
| 838 | ], |
| 839 | }) |
| 840 | for path in sorted(all_paths): |
| 841 | b = base_files.get(path) |
| 842 | l = left_files.get(path) |
| 843 | r = right_files.get(path) |
| 844 | |
| 845 | if l == r: |
| 846 | # Both sides agree — or both deleted (including convergent same change). |
| 847 | # "manual" must NOT fire here: when both branches agree (whether |
| 848 | # nothing changed or both independently made the same edit), there |
| 849 | # is no divergence to review. Firing manual on l == r produced |
| 850 | # false conflicts for every unchanged file matching a manual rule. |
| 851 | if l is None: |
| 852 | merged.pop(path, None) |
| 853 | else: |
| 854 | merged[path] = l |
| 855 | elif b == l: |
| 856 | # Only right changed. |
| 857 | if r is None: |
| 858 | merged.pop(path, None) |
| 859 | else: |
| 860 | merged[path] = r |
| 861 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 862 | conflicts.append(path) |
| 863 | applied_strategies[path] = "manual" |
| 864 | conflict_records.append(ConflictRecord( |
| 865 | path=path, |
| 866 | conflict_type="manual", |
| 867 | theirs_action=_file_action(b, r), |
| 868 | )) |
| 869 | elif b == r: |
| 870 | # Only left changed. |
| 871 | if l is None: |
| 872 | merged.pop(path, None) |
| 873 | else: |
| 874 | merged[path] = l |
| 875 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 876 | conflicts.append(path) |
| 877 | applied_strategies[path] = "manual" |
| 878 | conflict_records.append(ConflictRecord( |
| 879 | path=path, |
| 880 | conflict_type="manual", |
| 881 | ours_action=_file_action(b, l), |
| 882 | )) |
| 883 | else: |
| 884 | # Both sides changed differently — consult attributes. |
| 885 | strategy = resolve_strategy(attrs, path) if attrs else "auto" |
| 886 | if strategy == "ours": |
| 887 | if l is None: |
| 888 | merged.pop(path, None) |
| 889 | else: |
| 890 | merged[path] = l |
| 891 | applied_strategies[path] = "ours" |
| 892 | elif strategy == "theirs": |
| 893 | if r is None: |
| 894 | merged.pop(path, None) |
| 895 | else: |
| 896 | merged[path] = r |
| 897 | applied_strategies[path] = "theirs" |
| 898 | elif strategy == "base": |
| 899 | if b is None: |
| 900 | merged.pop(path, None) |
| 901 | else: |
| 902 | merged[path] = b |
| 903 | applied_strategies[path] = "base" |
| 904 | elif strategy == "union": |
| 905 | # Weave-based union: merge both sides' line-level changes. |
| 906 | # When repo_root is available, read the blobs and run |
| 907 | # _independence_merge_blob() so both sides' additions |
| 908 | # survive (Manyana add-wins at the text level). This is |
| 909 | # what makes docs/** and *.md union rules genuinely useful |
| 910 | # for concurrent prose additions. |
| 911 | # Binary blobs (contain null bytes) can't be text-merged — |
| 912 | # fall through to prefer-ours. |
| 913 | if repo_root is not None and b and l and r: |
| 914 | b_blob = read_object(repo_root, b) |
| 915 | l_blob = read_object(repo_root, l) |
| 916 | r_blob = read_object(repo_root, r) |
| 917 | if (b_blob is not None and l_blob is not None and r_blob is not None |
| 918 | and b"\x00" not in b_blob |
| 919 | and b"\x00" not in l_blob |
| 920 | and b"\x00" not in r_blob): |
| 921 | merged_bytes = _independence_merge_blob(b_blob, l_blob, r_blob) |
| 922 | merged_id = blob_id(merged_bytes) |
| 923 | write_object(repo_root, merged_id, merged_bytes) |
| 924 | merged[path] = merged_id |
| 925 | applied_strategies[path] = "union" |
| 926 | continue |
| 927 | # Fallback when blobs unavailable or binary: prefer ours. |
| 928 | merged[path] = l or r or b or "" |
| 929 | applied_strategies[path] = "union" |
| 930 | elif strategy == "manual": |
| 931 | conflicts.append(path) |
| 932 | merged[path] = l or r or b or "" |
| 933 | applied_strategies[path] = "manual" |
| 934 | conflict_records.append(ConflictRecord( |
| 935 | path=path, |
| 936 | conflict_type="manual", |
| 937 | ours_action=_file_action(b, l), |
| 938 | theirs_action=_file_action(b, r), |
| 939 | )) |
| 940 | else: |
| 941 | # "auto" — standard three-way conflict. |
| 942 | conflicts.append(path) |
| 943 | merged[path] = l or r or b or "" |
| 944 | conflict_records.append(ConflictRecord( |
| 945 | path=path, |
| 946 | conflict_type="file_level", |
| 947 | ours_action=_file_action(b, l), |
| 948 | theirs_action=_file_action(b, r), |
| 949 | )) |
| 950 | |
| 951 | merge_debug_log("plugin.merge.result", { |
| 952 | "conflicts": conflicts, |
| 953 | "conflict_records": [ |
| 954 | {"path": cr.path, "type": cr.conflict_type} |
| 955 | for cr in conflict_records |
| 956 | ], |
| 957 | "applied_strategies": applied_strategies, |
| 958 | }) |
| 959 | return MergeResult( |
| 960 | merged=SnapshotManifest( |
| 961 | files=merged, |
| 962 | domain=_DOMAIN_NAME, |
| 963 | directories=directories_from_manifest(merged), |
| 964 | ), |
| 965 | conflicts=conflicts, |
| 966 | applied_strategies=applied_strategies, |
| 967 | conflict_records=conflict_records, |
| 968 | ) |
| 969 | |
| 970 | # ------------------------------------------------------------------ |
| 971 | # 4. drift |
| 972 | # ------------------------------------------------------------------ |
| 973 | |
| 974 | def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport: |
| 975 | """Report how much the working tree has drifted from the last commit. |
| 976 | |
| 977 | Called by ``muse status``. Takes a snapshot of the current live |
| 978 | state and diffs it against the committed snapshot. |
| 979 | |
| 980 | Args: |
| 981 | committed: The last committed snapshot. |
| 982 | live: Current live state (path or snapshot manifest). |
| 983 | |
| 984 | Returns: |
| 985 | A ``DriftReport`` describing what has changed since the last commit. |
| 986 | """ |
| 987 | current = self.snapshot(live) |
| 988 | delta = self.diff(committed, current) |
| 989 | |
| 990 | # Remove delete ops for files that are in .museignore but still on |
| 991 | # disk. These were previously committed (exist in HEAD) but have |
| 992 | # since been added to .museignore to stop tracking them. Surfacing |
| 993 | # them as deleted in status or diff is misleading — they are present |
| 994 | # and unchanged; the user just does not want to track them anymore. |
| 995 | # Only file-level delete ops are considered (address has no "::"). |
| 996 | if isinstance(live, pathlib.Path) and delta.get("ops"): |
| 997 | ignore_patterns = load_ignore_patterns(live) |
| 998 | filtered = [ |
| 999 | op for op in delta["ops"] |
| 1000 | if not ( |
| 1001 | op.get("op") == "delete" |
| 1002 | and "::" not in op.get("address", "") |
| 1003 | and is_ignored(op["address"], ignore_patterns) |
| 1004 | and (live / op["address"]).exists() |
| 1005 | ) |
| 1006 | ] |
| 1007 | if len(filtered) != len(delta["ops"]): |
| 1008 | delta = StructuredDelta( |
| 1009 | domain=delta["domain"], |
| 1010 | ops=filtered, |
| 1011 | summary=delta_summary(filtered), |
| 1012 | ) |
| 1013 | |
| 1014 | return DriftReport( |
| 1015 | has_drift=len(delta["ops"]) > 0, |
| 1016 | summary=delta["summary"], |
| 1017 | delta=delta, |
| 1018 | ) |
| 1019 | |
| 1020 | # ------------------------------------------------------------------ |
| 1021 | # 5. apply |
| 1022 | # ------------------------------------------------------------------ |
| 1023 | |
| 1024 | def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: |
| 1025 | """Apply a delta to the working tree. |
| 1026 | |
| 1027 | Called by ``muse checkout`` after the core engine has already |
| 1028 | restored file-level objects from the object store. The code plugin |
| 1029 | has no domain-specific post-processing to perform, so this is a |
| 1030 | pass-through. |
| 1031 | |
| 1032 | Args: |
| 1033 | delta: The typed operation list (unused at post-checkout time). |
| 1034 | live_state: Current live state (returned unchanged). |
| 1035 | |
| 1036 | Returns: |
| 1037 | *live_state* unchanged. |
| 1038 | """ |
| 1039 | return live_state |
| 1040 | |
| 1041 | # ------------------------------------------------------------------ |
| 1042 | # 6. schema |
| 1043 | # ------------------------------------------------------------------ |
| 1044 | |
| 1045 | def schema(self) -> DomainSchema: |
| 1046 | """Declare the structural schema of the code domain. |
| 1047 | |
| 1048 | Returns: |
| 1049 | A ``DomainSchema`` with five semantic dimensions: |
| 1050 | ``structure``, ``symbols``, ``imports``, ``variables``, |
| 1051 | and ``metadata``. |
| 1052 | """ |
| 1053 | return DomainSchema( |
| 1054 | domain=_DOMAIN_NAME, |
| 1055 | description=( |
| 1056 | "Semantic version control for source code. " |
| 1057 | "Treats code as a structured system of named symbols " |
| 1058 | "(functions, classes, methods) rather than lines of text. " |
| 1059 | "Two commits that only reformat a file produce no delta. " |
| 1060 | "Renames and moves are detected via content-addressed " |
| 1061 | "symbol identity." |
| 1062 | ), |
| 1063 | top_level=TreeSchema( |
| 1064 | kind="tree", |
| 1065 | node_type="module", |
| 1066 | diff_algorithm="gumtree", |
| 1067 | ), |
| 1068 | dimensions=[ |
| 1069 | DimensionSpec( |
| 1070 | name="structure", |
| 1071 | description=( |
| 1072 | "Module / file tree. Tracks which files exist and " |
| 1073 | "how they relate to each other." |
| 1074 | ), |
| 1075 | schema=TreeSchema( |
| 1076 | kind="tree", |
| 1077 | node_type="file", |
| 1078 | diff_algorithm="gumtree", |
| 1079 | ), |
| 1080 | independent_merge=False, |
| 1081 | ), |
| 1082 | DimensionSpec( |
| 1083 | name="symbols", |
| 1084 | description=( |
| 1085 | "AST symbol tree. Functions, classes, methods, and " |
| 1086 | "variables — the primary unit of semantic change." |
| 1087 | ), |
| 1088 | schema=TreeSchema( |
| 1089 | kind="tree", |
| 1090 | node_type="symbol", |
| 1091 | diff_algorithm="gumtree", |
| 1092 | ), |
| 1093 | independent_merge=True, |
| 1094 | ), |
| 1095 | DimensionSpec( |
| 1096 | name="imports", |
| 1097 | description=( |
| 1098 | "Import set. Tracks added / removed import statements " |
| 1099 | "as an unordered set — order is semantically irrelevant." |
| 1100 | ), |
| 1101 | schema=SetSchema( |
| 1102 | kind="set", |
| 1103 | element_type="import", |
| 1104 | identity="by_content", |
| 1105 | ), |
| 1106 | independent_merge=True, |
| 1107 | ), |
| 1108 | DimensionSpec( |
| 1109 | name="variables", |
| 1110 | description=( |
| 1111 | "Top-level variable and constant assignments. " |
| 1112 | "Tracked as an unordered set." |
| 1113 | ), |
| 1114 | schema=SetSchema( |
| 1115 | kind="set", |
| 1116 | element_type="variable", |
| 1117 | identity="by_content", |
| 1118 | ), |
| 1119 | independent_merge=True, |
| 1120 | ), |
| 1121 | DimensionSpec( |
| 1122 | name="metadata", |
| 1123 | description=( |
| 1124 | "Non-code files: configuration, documentation, " |
| 1125 | "build scripts, etc. Tracked at file granularity." |
| 1126 | ), |
| 1127 | schema=SetSchema( |
| 1128 | kind="set", |
| 1129 | element_type="file", |
| 1130 | identity="by_content", |
| 1131 | ), |
| 1132 | independent_merge=True, |
| 1133 | ), |
| 1134 | ], |
| 1135 | merge_mode="three_way", |
| 1136 | schema_version=__version__, |
| 1137 | ) |
| 1138 | |
| 1139 | # ------------------------------------------------------------------ |
| 1140 | # AddressedMergePlugin — address-keyed map merge |
| 1141 | # ------------------------------------------------------------------ |
| 1142 | |
| 1143 | def merge_ops( |
| 1144 | self, |
| 1145 | base: StateSnapshot, |
| 1146 | ours_snap: StateSnapshot, |
| 1147 | theirs_snap: StateSnapshot, |
| 1148 | ours_ops: list[DomainOp], |
| 1149 | theirs_ops: list[DomainOp], |
| 1150 | *, |
| 1151 | repo_root: pathlib.Path | None = None, |
| 1152 | ) -> MergeResult: |
| 1153 | """Operation-level three-way merge using address-keyed map semantics. |
| 1154 | |
| 1155 | Uses :func:`~muse.core.op_merge.merge_op_lists` to determine |
| 1156 | which ``DomainOp`` pairs commute (auto-mergeable) and which conflict. |
| 1157 | For ``PatchOp`` entries at the same file address, the engine recurses |
| 1158 | into ``child_ops`` — so two agents modifying *different* functions in |
| 1159 | the same file produce a file-level conflict while concurrent |
| 1160 | modifications to the *same* function produce a symbol-level conflict |
| 1161 | address (e.g. ``"src/utils.py::calculate_total"``). |
| 1162 | |
| 1163 | **Conflict propagation** — the OT check operates at symbol granularity |
| 1164 | and can miss two classes of file-level conflict: |
| 1165 | |
| 1166 | 1. *Mixed op types*: one branch produced a ``ReplaceOp`` (no symbol |
| 1167 | tree available for that file type) and the other produced a |
| 1168 | ``PatchOp`` (symbol tree available). They never appear together |
| 1169 | in the OT conflict-detection loops. |
| 1170 | 2. *Commuting symbol changes*: two branches modify *different* symbols |
| 1171 | in the same file. The individual ``DomainOp``\\s commute so OT |
| 1172 | reports a clean merge, but Muse cannot reconstruct the merged file |
| 1173 | blob without a text-merge pass — the merged manifest would silently |
| 1174 | contain only the "ours" blob, discarding "theirs" changes. |
| 1175 | |
| 1176 | In both cases the file-level :meth:`merge` fallback correctly flags a |
| 1177 | conflict. ``merge_ops`` propagates those flags unless the path was |
| 1178 | already explicitly auto-resolved by a ``.museattributes`` strategy |
| 1179 | (``ours``, ``theirs``, ``base``, or ``union``). |
| 1180 | |
| 1181 | Args: |
| 1182 | base: Common ancestor snapshot. |
| 1183 | ours_snap: Our branch's final snapshot. |
| 1184 | theirs_snap: Their branch's final snapshot. |
| 1185 | ours_ops: Our branch's typed operation list. |
| 1186 | theirs_ops: Their branch's typed operation list. |
| 1187 | repo_root: Repository root for ``.museattributes`` lookup. |
| 1188 | |
| 1189 | Returns: |
| 1190 | A ``MergeResult`` where ``conflicts`` contains either symbol-level |
| 1191 | addresses (``"src/utils.py::calculate_total"``) or bare file paths |
| 1192 | (``"AGENTS.md"``) depending on the granularity at which the |
| 1193 | conflict was detected. |
| 1194 | """ |
| 1195 | # The core OT engine's _op_key for PatchOp hashes only the file path |
| 1196 | # and child_domain — not the child_ops themselves. This means two |
| 1197 | # PatchOps for the same file are treated as "consensus" regardless of |
| 1198 | # whether they touch the same or different symbols. We therefore |
| 1199 | # implement symbol-level conflict detection directly here. |
| 1200 | |
| 1201 | attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else [] |
| 1202 | |
| 1203 | # ── Step 1: symbol-level conflict detection for PatchOps ────────── |
| 1204 | ours_patches: PatchOpMap = { |
| 1205 | op["address"]: op for op in ours_ops if op["op"] == "patch" |
| 1206 | } |
| 1207 | theirs_patches: PatchOpMap = { |
| 1208 | op["address"]: op for op in theirs_ops if op["op"] == "patch" |
| 1209 | } |
| 1210 | |
| 1211 | merge_debug_log("merge_ops.enter", { |
| 1212 | "ours_patches_files": sorted(ours_patches.keys()), |
| 1213 | "theirs_patches_files": sorted(theirs_patches.keys()), |
| 1214 | "shared_patch_files": sorted(set(ours_patches) & set(theirs_patches)), |
| 1215 | }) |
| 1216 | conflict_addresses: set[str] = set() |
| 1217 | for path in ours_patches: |
| 1218 | if path not in theirs_patches: |
| 1219 | continue |
| 1220 | for our_child in ours_patches[path]["child_ops"]: |
| 1221 | for their_child in theirs_patches[path]["child_ops"]: |
| 1222 | if not ops_commute(our_child, their_child): |
| 1223 | merge_debug_log("merge_ops.symbol_conflict", { |
| 1224 | "file": path, |
| 1225 | "ours_address": our_child["address"], |
| 1226 | "theirs_address": their_child["address"], |
| 1227 | }) |
| 1228 | conflict_addresses.add(our_child["address"]) |
| 1229 | merge_debug_log("merge_ops.step1_result", { |
| 1230 | "conflict_addresses_after_step1": sorted(conflict_addresses), |
| 1231 | }) |
| 1232 | |
| 1233 | # ── Step 1.5: independence-aware blob reconstruction ────────────── |
| 1234 | # For files where both sides changed the blob AND all symbol-level ops |
| 1235 | # commute, reconstruct the merged blob via _independence_merge_blob() |
| 1236 | # instead of letting the file-level fallback flag a manifest conflict. |
| 1237 | # This implements OR-Set semantics for imports and variables, and |
| 1238 | # independence-aware merge for functions and classes: concurrent |
| 1239 | # additions of non-overlapping symbols always merge cleanly regardless |
| 1240 | # of their line-level positions. |
| 1241 | # |
| 1242 | # We derive fresh PatchOps from the snapshots when the provided op |
| 1243 | # lists don't cover a changed path (e.g. when called with empty lists). |
| 1244 | # This keeps the test surface thin while ensuring correctness in all |
| 1245 | # callers. |
| 1246 | independence_resolved: dict[str, str] = {} # path → merged_content_id |
| 1247 | |
| 1248 | if repo_root is not None: |
| 1249 | conflict_file_paths: set[str] = { |
| 1250 | (addr.split("::")[0] if "::" in addr else addr) |
| 1251 | for addr in conflict_addresses |
| 1252 | } |
| 1253 | # Candidate paths: both sides changed the same file from base. |
| 1254 | base_files = base["files"] |
| 1255 | candidate_paths = sorted( |
| 1256 | p |
| 1257 | for p in set(ours_snap["files"]) | set(theirs_snap["files"]) |
| 1258 | if ( |
| 1259 | p not in conflict_file_paths |
| 1260 | and ours_snap["files"].get(p) != theirs_snap["files"].get(p) |
| 1261 | and base_files.get(p) != ours_snap["files"].get(p) |
| 1262 | and base_files.get(p) != theirs_snap["files"].get(p) |
| 1263 | and ours_snap["files"].get(p) is not None |
| 1264 | and theirs_snap["files"].get(p) is not None |
| 1265 | and base_files.get(p) is not None |
| 1266 | ) |
| 1267 | ) |
| 1268 | |
| 1269 | for path in candidate_paths: |
| 1270 | b_id = base_files[path] |
| 1271 | l_id = ours_snap["files"][path] |
| 1272 | r_id = theirs_snap["files"][path] |
| 1273 | |
| 1274 | # Check symbol independence — use provided PatchOps when |
| 1275 | # available, otherwise derive them from the snapshot diff. |
| 1276 | our_patch = ours_patches.get(path) |
| 1277 | their_patch = theirs_patches.get(path) |
| 1278 | if our_patch is None or their_patch is None: |
| 1279 | # Derive ops lazily for this path only. |
| 1280 | derived_ours = _semantic_ops( |
| 1281 | base_files, ours_snap["files"], repo_root |
| 1282 | ) |
| 1283 | derived_theirs = _semantic_ops( |
| 1284 | base_files, theirs_snap["files"], repo_root |
| 1285 | ) |
| 1286 | our_patch = next( |
| 1287 | (op for op in derived_ours if op["op"] == "patch" and op["address"] == path), |
| 1288 | None, |
| 1289 | ) |
| 1290 | their_patch = next( |
| 1291 | (op for op in derived_theirs if op["op"] == "patch" and op["address"] == path), |
| 1292 | None, |
| 1293 | ) |
| 1294 | |
| 1295 | if our_patch is None or their_patch is None: |
| 1296 | # No symbol info for this path — can't confirm independence. |
| 1297 | continue |
| 1298 | |
| 1299 | # Don't union-merge when neither side produced any symbols — the |
| 1300 | # file couldn't be parsed, so "no symbol conflicts" means "we have |
| 1301 | # no information", not "safe to merge". Letting _independence_merge_blob |
| 1302 | # fire here would silently write both divergent versions as union output |
| 1303 | # without surfacing a conflict (data corruption). |
| 1304 | if not our_patch["child_ops"] and not their_patch["child_ops"]: |
| 1305 | continue |
| 1306 | |
| 1307 | file_has_symbol_conflict = any( |
| 1308 | not ops_commute(oc, tc) |
| 1309 | for oc in our_patch["child_ops"] |
| 1310 | for tc in their_patch["child_ops"] |
| 1311 | ) |
| 1312 | if file_has_symbol_conflict: |
| 1313 | continue # genuine symbol conflict — let it surface normally |
| 1314 | |
| 1315 | # Independence merge is only safe when the two sides touch |
| 1316 | # DISJOINT symbol addresses (or share only convergent inserts — |
| 1317 | # the same symbol added with identical content on both sides, |
| 1318 | # which is idempotent). |
| 1319 | # |
| 1320 | # delete+delete on the SAME address "commutes" in the OT sense |
| 1321 | # (consensus delete), but the surrounding file bytes may still |
| 1322 | # diverge (e.g. both sides replaced an unparseable file and |
| 1323 | # produced "delete the base symbols" vacuously). Firing |
| 1324 | # independence_merge_blob in that case silently union-merges |
| 1325 | # genuinely conflicting content. |
| 1326 | our_addr_ops = {op["address"]: op for op in our_patch["child_ops"]} |
| 1327 | their_addr_ops = {op["address"]: op for op in their_patch["child_ops"]} |
| 1328 | shared_addrs = set(our_addr_ops) & set(their_addr_ops) |
| 1329 | if any( |
| 1330 | not ( |
| 1331 | our_addr_ops[addr]["op"] == "insert" |
| 1332 | and their_addr_ops[addr]["op"] == "insert" |
| 1333 | and our_addr_ops[addr].get("content_id") |
| 1334 | == their_addr_ops[addr].get("content_id") |
| 1335 | ) |
| 1336 | for addr in shared_addrs |
| 1337 | ): |
| 1338 | continue # non-convergent overlap — can't confirm independence |
| 1339 | |
| 1340 | b_blob = read_object(repo_root, b_id) |
| 1341 | l_blob = read_object(repo_root, l_id) |
| 1342 | r_blob = read_object(repo_root, r_id) |
| 1343 | if b_blob is None or l_blob is None or r_blob is None: |
| 1344 | continue # can't read blobs — fall through to file-level conflict |
| 1345 | |
| 1346 | merged_bytes = _independence_merge_blob(b_blob, l_blob, r_blob) |
| 1347 | merged_id = blob_id(merged_bytes) |
| 1348 | write_object(repo_root, merged_id, merged_bytes) |
| 1349 | independence_resolved[path] = merged_id |
| 1350 | |
| 1351 | # ── Step 2: coarse OT for non-PatchOp ops (file-level inserts/deletes) ── |
| 1352 | non_patch_ours: list[DomainOp] = [op for op in ours_ops if op["op"] != "patch"] |
| 1353 | non_patch_theirs: list[DomainOp] = [op for op in theirs_ops if op["op"] != "patch"] |
| 1354 | merge_debug_log("merge_ops.step2_input", { |
| 1355 | "non_patch_ours": non_patch_ours, |
| 1356 | "non_patch_theirs": non_patch_theirs, |
| 1357 | }) |
| 1358 | file_result = merge_op_lists( |
| 1359 | base_ops=[], |
| 1360 | ours_ops=non_patch_ours, |
| 1361 | theirs_ops=non_patch_theirs, |
| 1362 | ) |
| 1363 | for our_op, _ in file_result.conflict_ops: |
| 1364 | merge_debug_log("merge_ops.step2_conflict", {"address": our_op["address"]}) |
| 1365 | conflict_addresses.add(our_op["address"]) |
| 1366 | merge_debug_log("merge_ops.step2_result", { |
| 1367 | "conflict_addresses_after_step2": sorted(conflict_addresses), |
| 1368 | }) |
| 1369 | |
| 1370 | # ── Step 3: apply .museattributes to symbol-level conflicts ────── |
| 1371 | # Symbol addresses are of the form "src/utils.py::function_name". |
| 1372 | # We resolve strategy against the file path portion so that a |
| 1373 | # path = "src/**/*.py" / strategy = "ours" rule suppresses symbol |
| 1374 | # conflicts in those files, not just file-level manifest conflicts. |
| 1375 | op_applied_strategies: AppliedStrategies = {} |
| 1376 | resolved_conflicts: list[str] = [] |
| 1377 | if attrs: |
| 1378 | for addr in sorted(conflict_addresses): |
| 1379 | file_path = addr.split("::")[0] if "::" in addr else addr |
| 1380 | strategy = resolve_strategy(attrs, file_path) |
| 1381 | if strategy in ("ours", "theirs", "base", "union"): |
| 1382 | op_applied_strategies[addr] = strategy |
| 1383 | elif strategy == "manual": |
| 1384 | resolved_conflicts.append(addr) |
| 1385 | op_applied_strategies[addr] = "manual" |
| 1386 | else: |
| 1387 | resolved_conflicts.append(addr) |
| 1388 | else: |
| 1389 | resolved_conflicts = sorted(conflict_addresses) |
| 1390 | |
| 1391 | merged_ops: list[DomainOp] = list(file_result.merged_ops) + list(ours_ops) |
| 1392 | |
| 1393 | # Fall back to file-level merge for the manifest (carries its own |
| 1394 | # applied_strategies from file-level attribute resolution). |
| 1395 | fallback = self.merge(base, ours_snap, theirs_snap, repo_root=repo_root) |
| 1396 | combined_strategies = {**fallback.applied_strategies, **op_applied_strategies} |
| 1397 | |
| 1398 | # Patch the manifest and conflict list with independence-resolved files. |
| 1399 | # These paths were auto-merged at the symbol level and must not appear |
| 1400 | # in the final conflict list even if the raw blob IDs differ. |
| 1401 | if independence_resolved: |
| 1402 | patched_files = dict(fallback.merged["files"]) |
| 1403 | patched_files.update(independence_resolved) |
| 1404 | fallback = MergeResult( |
| 1405 | merged=SnapshotManifest( |
| 1406 | files=patched_files, |
| 1407 | domain=_DOMAIN_NAME, |
| 1408 | directories=directories_from_manifest(patched_files), |
| 1409 | ), |
| 1410 | conflicts=[p for p in fallback.conflicts if p not in independence_resolved], |
| 1411 | applied_strategies=fallback.applied_strategies, |
| 1412 | conflict_records=fallback.conflict_records, |
| 1413 | ) |
| 1414 | |
| 1415 | # ── Step 4: propagate file-level conflicts missed by the OT check ── |
| 1416 | # Extract the file path from each OT-detected conflict address so we |
| 1417 | # can avoid duplicating conflicts that are already represented at the |
| 1418 | # symbol level (e.g. "src/utils.py::my_func" covers "src/utils.py"). |
| 1419 | auto_resolved_file_paths: set[str] = { |
| 1420 | (addr.split("::")[0] if "::" in addr else addr) |
| 1421 | for addr, strat in combined_strategies.items() |
| 1422 | if strat in ("ours", "theirs", "base", "union") |
| 1423 | } |
| 1424 | ot_conflict_file_paths: set[str] = { |
| 1425 | (addr.split("::")[0] if "::" in addr else addr) |
| 1426 | for addr in resolved_conflicts |
| 1427 | } |
| 1428 | propagated_file_conflicts: list[str] = [ |
| 1429 | p for p in fallback.conflicts |
| 1430 | if p not in auto_resolved_file_paths and p not in ot_conflict_file_paths |
| 1431 | ] |
| 1432 | all_conflicts: list[str] = resolved_conflicts + sorted(propagated_file_conflicts) |
| 1433 | |
| 1434 | merge_debug_log("merge_ops.result", { |
| 1435 | "fallback_conflicts": fallback.conflicts, |
| 1436 | "resolved_conflicts_from_OT": resolved_conflicts, |
| 1437 | "propagated_file_conflicts": sorted(propagated_file_conflicts), |
| 1438 | "auto_resolved_file_paths": sorted(auto_resolved_file_paths), |
| 1439 | "ot_conflict_file_paths": sorted(ot_conflict_file_paths), |
| 1440 | "all_conflicts_final": all_conflicts, |
| 1441 | "independence_resolved": sorted(independence_resolved.keys()), |
| 1442 | }) |
| 1443 | return MergeResult( |
| 1444 | merged=fallback.merged, |
| 1445 | conflicts=all_conflicts, |
| 1446 | applied_strategies=combined_strategies, |
| 1447 | dimension_reports=fallback.dimension_reports, |
| 1448 | op_log=merged_ops, |
| 1449 | ) |
| 1450 | |
| 1451 | # --------------------------------------------------------------------------- |
| 1452 | # Private helpers |
| 1453 | # --------------------------------------------------------------------------- |
| 1454 | |
| 1455 | def _independence_merge_blob( |
| 1456 | base_blob: bytes, |
| 1457 | ours_blob: bytes, |
| 1458 | theirs_blob: bytes, |
| 1459 | ) -> bytes: |
| 1460 | """Reconstruct a merged blob for a file whose symbol changes are independent. |
| 1461 | |
| 1462 | When ``merge_ops()`` confirms all child op pairs commute (no symbol-level |
| 1463 | conflict), this function produces the merged file content. |
| 1464 | |
| 1465 | Strategy |
| 1466 | -------- |
| 1467 | 1. Run ``three_way_merge_lines`` — if clean (no conflict regions), return it. |
| 1468 | 2. If the text merge has conflicts, those conflicts are spurious: they arise |
| 1469 | from concurrent insertions at the same line position (both sides added new |
| 1470 | content at the same anchor point in the file — e.g. two new functions at |
| 1471 | the end, or two new imports at the top). For independent symbols this is |
| 1472 | always safe to union-merge: include ours' additions then theirs' additions |
| 1473 | in document order. This is the Manyana add-wins principle applied at the |
| 1474 | text level. |
| 1475 | |
| 1476 | The union resolution is applied only here, gated by the caller having already |
| 1477 | verified symbol-level independence. Callers that detect a genuine symbol |
| 1478 | conflict take the normal conflict path and never reach this function. |
| 1479 | """ |
| 1480 | b_lines = base_blob.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 1481 | l_lines = ours_blob.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 1482 | r_lines = theirs_blob.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 1483 | |
| 1484 | from muse.core.cohen_transform import three_way_merge_lines |
| 1485 | merged_lines, has_conflict = three_way_merge_lines(b_lines, l_lines, r_lines) |
| 1486 | if not has_conflict: |
| 1487 | return "".join(merged_lines).encode("utf-8") |
| 1488 | |
| 1489 | # Spurious conflict from concurrent insertions — union-resolve every conflict |
| 1490 | # region by including ours' lines then theirs' lines (Manyana add-wins). |
| 1491 | regions = compute_regions(b_lines, l_lines, r_lines) |
| 1492 | output: list[str] = [] |
| 1493 | for region in regions: |
| 1494 | if region.kind == "stable": |
| 1495 | output.extend(region.base_lines) |
| 1496 | elif region.kind == "ours_only": |
| 1497 | output.extend(region.ours_lines) |
| 1498 | elif region.kind == "theirs_only": |
| 1499 | output.extend(region.theirs_lines) |
| 1500 | elif region.kind == "both_same": |
| 1501 | output.extend(region.ours_lines) |
| 1502 | else: |
| 1503 | # conflict → union: ours additions then theirs additions |
| 1504 | output.extend(region.ours_lines) |
| 1505 | output.extend(region.theirs_lines) |
| 1506 | return "".join(output).encode("utf-8") |
| 1507 | |
| 1508 | def _file_action(base_id: str | None, other_id: str | None) -> str: |
| 1509 | """Return the Cohen-transform action label for one side of a file conflict. |
| 1510 | |
| 1511 | Compares the *other* side's object ID against the common ancestor: |
| 1512 | |
| 1513 | - ``"inserted"`` — the file was added from nothing (base absent, other present). |
| 1514 | - ``"deleted"`` — the file was removed (base present, other absent). |
| 1515 | - ``"modified"`` — the file existed on both sides but the content changed. |
| 1516 | """ |
| 1517 | if base_id is None and other_id is not None: |
| 1518 | return "inserted" |
| 1519 | if base_id is not None and other_id is None: |
| 1520 | return "deleted" |
| 1521 | return "modified" |
| 1522 | |
| 1523 | def _read_blob( |
| 1524 | repo_root: pathlib.Path, |
| 1525 | content_id: str, |
| 1526 | disk_fallback: pathlib.Path | None, |
| 1527 | ) -> bytes | None: |
| 1528 | """Read a blob from the object store; fall back to disk when not found. |
| 1529 | |
| 1530 | When ``disk_fallback`` is provided and the object store returns ``None`` |
| 1531 | (blob not yet committed — typical during ``muse diff`` on the working |
| 1532 | tree), we read the file directly from disk and verify its SHA-256 matches |
| 1533 | ``content_id`` before returning it. This guarantees we never parse stale |
| 1534 | content from a file whose hash has changed since the snapshot was taken. |
| 1535 | """ |
| 1536 | raw = read_object(repo_root, content_id) |
| 1537 | if raw is not None: |
| 1538 | return raw |
| 1539 | if disk_fallback is None or not disk_fallback.is_file(): |
| 1540 | return None |
| 1541 | try: |
| 1542 | candidate = disk_fallback.read_bytes() |
| 1543 | except OSError: |
| 1544 | return None |
| 1545 | if hash_file(disk_fallback) == content_id: |
| 1546 | return candidate |
| 1547 | return None |
| 1548 | |
| 1549 | def _semantic_ops( |
| 1550 | base_files: Manifest, |
| 1551 | target_files: Manifest, |
| 1552 | repo_root: pathlib.Path, |
| 1553 | workdir: pathlib.Path | None = None, |
| 1554 | ) -> list[DomainOp]: |
| 1555 | """Produce symbol-level ops by reading files from the object store. |
| 1556 | |
| 1557 | When *workdir* is supplied (working-tree diffs), blobs that are not yet |
| 1558 | in the object store are read directly from disk and verified against their |
| 1559 | content hash. This enables full semantic diffing for ``muse diff`` before |
| 1560 | a commit has been made. |
| 1561 | """ |
| 1562 | base_paths = set(base_files) |
| 1563 | target_paths = set(target_files) |
| 1564 | changed_paths = ( |
| 1565 | (target_paths - base_paths) # added |
| 1566 | | (base_paths - target_paths) # removed |
| 1567 | | { # modified |
| 1568 | p for p in base_paths & target_paths |
| 1569 | if base_files[p] != target_files[p] |
| 1570 | } |
| 1571 | ) |
| 1572 | |
| 1573 | base_trees: SymbolTreeMap = {} |
| 1574 | target_trees: SymbolTreeMap = {} |
| 1575 | |
| 1576 | for path in changed_paths: |
| 1577 | if path in base_files: |
| 1578 | raw = _read_blob(repo_root, base_files[path], None) |
| 1579 | if raw is not None: |
| 1580 | base_trees[path] = _parse_with_fallback(raw, path) |
| 1581 | |
| 1582 | if path in target_files: |
| 1583 | disk_path = (workdir / path) if workdir is not None else None |
| 1584 | raw = _read_blob(repo_root, target_files[path], disk_path) |
| 1585 | if raw is not None: |
| 1586 | target_trees[path] = _parse_with_fallback(raw, path) |
| 1587 | |
| 1588 | return build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1589 | |
| 1590 | def _parse_with_fallback(source: bytes, file_path: str) -> SymbolTree: |
| 1591 | """Parse symbols from *source*, returning an empty tree on any error.""" |
| 1592 | try: |
| 1593 | return parse_symbols(source, file_path) |
| 1594 | except Exception: |
| 1595 | logger.debug("Symbol parsing failed for %s — falling back to file-level.", file_path) |
| 1596 | return {} |
| 1597 | |
| 1598 | # --------------------------------------------------------------------------- |
| 1599 | # Module-level singleton |
| 1600 | # --------------------------------------------------------------------------- |
| 1601 | |
| 1602 | #: The singleton plugin instance registered in ``muse/plugins/registry.py``. |
| 1603 | plugin = CodePlugin() |
File History
4 commits
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9
Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump…
Human
13 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
70 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f
chore: bump version to 0.2.0rc15 to match musehub#113 fix release
Sonnet 4.6
patch
70 days ago
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70
feat: migrate invariants cache to .muse/cache/invariants.ms…
Sonnet 4.6
patch
138 days ago