gabriel / muse public
plugin.py python
1,128 lines 44.8 KB
Raw
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