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