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