plan_merge.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """``muse coord plan-merge`` β€” dry-run semantic merge planning.
2
3 Computes which symbols diverged between two commits using a true **three-way
4 merge** (base + ours + theirs), classifies every conflict by a semantic
5 taxonomy, and recommends a merge strategy β€” without writing anything to disk.
6
7 The base commit is the Lowest Common Ancestor (LCA) of ``OURS`` and
8 ``THEIRS``, computed automatically via :func:`~muse.core.merge_engine.find_merge_base`.
9 You can supply ``--base`` to override it (useful for first-commit repos or
10 disconnected branches). When no common ancestor exists and ``--base`` is
11 absent, the analysis falls back to a two-way diff with reduced accuracy.
12
13 Three-way merge semantics
14 --------------------------
15 A three-way merge is the foundation of correct conflict detection:
16
17 * ``base=X, ours=X, theirs=Y`` β†’ only theirs changed β†’ **no conflict**
18 * ``base=X, ours=Y, theirs=X`` β†’ only ours changed β†’ **no conflict**
19 * ``base=X, ours=Y, theirs=Z`` β†’ both changed β†’ **conflict**
20 * ``base=None, ours=Y, theirs=Z`` β†’ both added independently β†’ **conflict** (if content differs)
21
22 Without a base, the "only one side changed" cases above are
23 indistinguishable from "both changed", producing false positives.
24
25 Conflict taxonomy
26 -----------------
27 ``symbol_edit_overlap``
28 Both branches modified the same symbol. Content diverged β€” three-way
29 merge required. Confidence: **1.00**.
30
31 ``rename_edit``
32 One branch renamed a symbol (same :attr:`body_hash`, new address). The
33 other edited that symbol's body. A text merge would silently corrupt the
34 codebase. Confidence: **0.90**.
35
36 ``move_edit``
37 One branch moved a symbol to a different file (same :attr:`body_hash`,
38 different file prefix in address). The other modified the original.
39 Confidence: **0.90**.
40
41 ``delete_use``
42 One branch deleted a symbol; the other added **new call sites** for it.
43 Detected by comparing the forward call graphs of base and the surviving
44 branch. Requires the code index β€” skipped with a warning if unavailable.
45 Confidence: **0.95**.
46
47 ``dependency_conflict``
48 Branch A changed symbol X; Branch B's changes include a symbol that
49 transitively calls X. Detected via reverse call graph. Requires the
50 code index. Confidence: **0.75**.
51
52 ``no_conflict``
53 Symbol was changed on only one branch, or is identical on both, or was
54 deleted on both. Safe to auto-merge.
55
56 Usage::
57
58 muse coord plan-merge HEAD main
59 muse coord plan-merge feature/billing main
60 muse coord plan-merge feature/billing main --base a1b2c3d4
61 muse coord plan-merge feature/billing main --skip-call-graph
62 muse coord plan-merge feature/billing main --format json
63 muse coord plan-merge feature/billing main --json
64
65 JSON output schema::
66
67 {
68 "schema_version": str,
69 "ours": str, // full commit ID
70 "theirs": str, // full commit ID
71 "base": str | null, // LCA commit ID, or null
72 "base_auto_computed": bool,
73 "call_graph_available": bool,
74 "call_graph_skipped": bool,
75 "warnings": [str, ...],
76 "total_symbols": int,
77 "conflicts": int,
78 "clean": int,
79 "conflicts_by_type": {"symbol_edit_overlap": int, "rename_edit": int, ...},
80 "items": [
81 {
82 "address": str,
83 "conflict_type": str,
84 "ours_change": str,
85 "theirs_change": str,
86 "recommendation": str
87 },
88 ...
89 ],
90 "elapsed_seconds": float
91 }
92
93 Exit codes::
94
95 0 β€” success
96 1 β€” one or both refs not found, or bad arguments
97 """
98
99 from __future__ import annotations
100
101 import argparse
102 import json
103 import logging
104 import pathlib
105 import sys
106 import time
107 from typing import TypedDict
108
109 from muse._version import __version__
110 from muse.core.errors import ExitCode
111 from muse.core.merge_engine import find_merge_base
112 from muse.core.repo import read_repo_id, require_repo
113 from muse.core.store import (
114 Manifest,
115 get_commit_snapshot_manifest,
116 read_current_branch,
117 read_commit,
118 resolve_commit_ref,
119 )
120 from muse.plugins.code._query import symbols_for_snapshot
121 from muse.plugins.code.ast_parser import SymbolRecord
122 from muse.core.validation import sanitize_display
123
124 logger = logging.getLogger(__name__)
125
126 type SymbolMap = dict[str, SymbolRecord] # address β†’ symbol record
127 type BodyHashMap = dict[str, list[str]] # body_hash β†’ list of addresses
128 type AddressMap = dict[str, str] # old_address β†’ new_address (renames/moves)
129 type ConflictTypeCount = dict[str, int] # conflict_type β†’ count
130 type MergeItemIndex = dict[str, _MergeItem] # address β†’ merge item
131
132
133 # ── Error helper ──────────────────────────────────────────────────────────────
134
135
136 def _err(message: str, as_json: bool, code: int) -> None:
137 """Print an error to stdout (JSON) or stderr (text) and exit. Never returns.
138
139 Args:
140 message: Human-readable error description.
141 as_json: When ``True``, emit compact JSON ``{"error": ..., "status": "error"}``
142 to *stdout*; otherwise emit ``❌ {message}`` to *stderr*.
143 code: :class:`~muse.core.errors.ExitCode` value to pass to
144 :class:`SystemExit`.
145 """
146 if as_json:
147 print(json.dumps({"error": message, "status": "error"}))
148 else:
149 print(f"❌ {message}", file=sys.stderr)
150 raise SystemExit(code)
151
152
153 # ── Data model ────────────────────────────────────────────────────────────────
154
155
156 class _MergeItemDict(TypedDict):
157 """JSON-serialisable form of a :class:`_MergeItem`."""
158
159 address: str
160 conflict_type: str
161 ours_change: str
162 theirs_change: str
163 recommendation: str
164
165
166 class _MergeItem:
167 """One symbol's classification in the merge plan.
168
169 Attributes
170 ----------
171 address:
172 Full symbol address, e.g. ``"src/billing.py::compute_total"``.
173 conflict_type:
174 One of the taxonomy values: ``"symbol_edit_overlap"``, ``"rename_edit"``,
175 ``"move_edit"``, ``"delete_use"``, ``"dependency_conflict"``,
176 ``"no_conflict"``.
177 ours_change:
178 Human-readable description of how *ours* changed this symbol relative
179 to base (e.g. ``"impl_only"``, ``"renamed to X"``, ``"deleted"``).
180 theirs_change:
181 Same for *theirs*.
182 recommendation:
183 A concrete, actionable suggestion for the person integrating the merge.
184 """
185
186 __slots__ = ("address", "conflict_type", "ours_change", "theirs_change", "recommendation")
187
188 def __init__(
189 self,
190 address: str,
191 conflict_type: str,
192 ours_change: str,
193 theirs_change: str,
194 recommendation: str,
195 ) -> None:
196 self.address = address
197 self.conflict_type = conflict_type
198 self.ours_change = ours_change
199 self.theirs_change = theirs_change
200 self.recommendation = recommendation
201
202 def to_dict(self) -> _MergeItemDict:
203 return {
204 "address": self.address,
205 "conflict_type": self.conflict_type,
206 "ours_change": self.ours_change,
207 "theirs_change": self.theirs_change,
208 "recommendation": self.recommendation,
209 }
210
211
212 # ── Classification helpers ────────────────────────────────────────────────────
213
214
215 def _classify_change(base: SymbolRecord, target: SymbolRecord) -> str:
216 """Describe how *target* differs from *base* for a single symbol.
217
218 Uses the three orthogonal hash dimensions of :class:`~muse.plugins.code.ast_parser.SymbolRecord`:
219
220 * ``content_id`` β€” the full symbol (name + signature + body) is unchanged.
221 * ``body_hash`` β€” body changed (implementation change); if name also
222 changed it's a rename+modify.
223 * ``signature_id`` β€” signature (name + args + return type) changed but body
224 did not (signature-only / rename).
225
226 Returns
227 -------
228 str
229 One of ``"unchanged"``, ``"signature_only"``, ``"impl_only"``,
230 ``"rename+modify"``, ``"metadata_only"``, ``"full_rewrite"``.
231 """
232 if base["content_id"] == target["content_id"]:
233 return "unchanged"
234 if base["body_hash"] == target["body_hash"] and base["signature_id"] == target["signature_id"]:
235 # Only metadata changed (decorators, async flag, etc.).
236 return "metadata_only"
237 if base["body_hash"] == target["body_hash"]:
238 # Body unchanged, signature changed β†’ rename or type-annotation tweak.
239 return "signature_only"
240 if base["signature_id"] == target["signature_id"]:
241 # Signature unchanged, body changed β†’ pure implementation edit.
242 return "impl_only"
243 if base["name"] != target["name"]:
244 # Both name and body changed simultaneously.
245 return "rename+modify"
246 return "full_rewrite"
247
248
249 def _classify_conflict(
250 addr: str,
251 base: SymbolRecord | None,
252 ours: SymbolRecord | None,
253 theirs: SymbolRecord | None,
254 ) -> _MergeItem:
255 """Classify the merge conflict for a single symbol using three-way semantics.
256
257 Three-way merge rules
258 ---------------------
259 ``base=X, ours=X, theirs=Y`` β†’ only theirs changed β†’ no_conflict
260 ``base=X, ours=Y, theirs=X`` β†’ only ours changed β†’ no_conflict
261 ``base=X, ours=Y, theirs=Z`` β†’ both changed β†’ symbol_edit_overlap or rename_edit
262 ``base=None, ours=Y, theirs=Z`` β†’ both added new β†’ no_conflict (identical) or overlap
263 ``base=X, ours=None, theirs=None`` β†’ both deleted β†’ no_conflict
264 ``base=X, ours=None, theirs=Y`` β†’ ours deleted only β†’ no_conflict (delete_use handled separately)
265 ``base=X, ours=Y, theirs=None`` β†’ theirs deleted only β†’ no_conflict (delete_use handled separately)
266
267 Parameters
268 ----------
269 addr:
270 The full symbol address being classified.
271 base:
272 The symbol record at the merge base commit, or ``None`` if the symbol
273 did not exist yet at the base.
274 ours:
275 The symbol record on the *ours* branch, or ``None`` if deleted/absent.
276 theirs:
277 The symbol record on the *theirs* branch, or ``None`` if deleted/absent.
278
279 Returns
280 -------
281 _MergeItem
282 A classified merge item. ``delete_use`` and ``move_edit`` conflicts
283 that require cross-symbol or call-graph analysis are detected in
284 separate passes β€” this function never returns those types.
285 """
286 # ── Both absent (should not appear in all_addrs, but be safe) ─────────────
287 if ours is None and theirs is None:
288 return _MergeItem(addr, "no_conflict", "deleted", "deleted", "nothing to merge")
289
290 # ── Only ours has it (ours added, theirs never had it or deleted) ──────────
291 if ours is not None and theirs is None:
292 if base is None:
293 return _MergeItem(addr, "no_conflict", "added", "absent", "apply ours (insert)")
294 # base exists, theirs deleted it.
295 our_change = _classify_change(base, ours)
296 if our_change == "unchanged":
297 return _MergeItem(addr, "no_conflict", "unchanged", "deleted", "apply theirs (delete)")
298 # Ours modified AND theirs deleted β†’ potential delete_use, handled in separate pass.
299 return _MergeItem(addr, "no_conflict", our_change, "deleted",
300 "review: ours modified, theirs deleted")
301
302 # ── Only theirs has it ─────────────────────────────────────────────────────
303 if theirs is not None and ours is None:
304 if base is None:
305 return _MergeItem(addr, "no_conflict", "absent", "added", "apply theirs (insert)")
306 their_change = _classify_change(base, theirs)
307 if their_change == "unchanged":
308 return _MergeItem(addr, "no_conflict", "deleted", "unchanged", "apply ours (delete)")
309 return _MergeItem(addr, "no_conflict", "deleted", their_change,
310 "review: theirs modified, ours deleted")
311
312 # ── Both have it ───────────────────────────────────────────────────────────
313 assert ours is not None and theirs is not None
314
315 # Identical on both β†’ no conflict.
316 if ours["content_id"] == theirs["content_id"]:
317 return _MergeItem(addr, "no_conflict", "same", "same", "auto-merge (identical)")
318
319 if base is not None:
320 our_change = _classify_change(base, ours)
321 their_change = _classify_change(base, theirs)
322
323 # Only one side changed β†’ no conflict.
324 if our_change == "unchanged":
325 return _MergeItem(addr, "no_conflict", "unchanged", their_change,
326 "apply theirs (fast-forward)")
327 if their_change == "unchanged":
328 return _MergeItem(addr, "no_conflict", our_change, "unchanged",
329 "apply ours (fast-forward)")
330
331 # Both changed relative to base.
332 # Rename detection: same body hash but different name β†’ signature_only
333 # rename on one side is surfaced as rename_edit.
334 if (
335 our_change in ("signature_only", "rename+modify")
336 and ours["body_hash"] == base["body_hash"]
337 and ours["name"] != base["name"]
338 ):
339 return _MergeItem(addr, "rename_edit", our_change, their_change,
340 "manual: rename ours, rebase theirs onto new name")
341 if (
342 their_change in ("signature_only", "rename+modify")
343 and theirs["body_hash"] == base["body_hash"]
344 and theirs["name"] != base["name"]
345 ):
346 return _MergeItem(addr, "rename_edit", our_change, their_change,
347 "manual: rebase ours onto theirs' rename")
348
349 return _MergeItem(addr, "symbol_edit_overlap", our_change, their_change,
350 "manual: three-way merge required")
351
352 # No base β€” both added independently.
353 return _MergeItem(addr, "symbol_edit_overlap", "added", "added",
354 "manual: both branches added this symbol with different content")
355
356
357 # ── Rename and move detection ─────────────────────────────────────────────────
358
359
360 def _build_body_hash_map(syms: SymbolMap) -> BodyHashMap:
361 """Return a mapping of ``body_hash β†’ [address, ...]`` for *syms*.
362
363 Enables O(1) lookup of a symbol's new location after a rename or move.
364 Trivial body hashes (empty bodies shared across many symbols) could
365 produce false positives; the caller should validate further.
366 """
367 result: BodyHashMap = {}
368 for addr, rec in syms.items():
369 result.setdefault(rec["body_hash"], []).append(addr)
370 return result
371
372
373 def _find_renames_and_moves(
374 base_syms: SymbolMap,
375 branch_syms: SymbolMap,
376 ) -> tuple[AddressMap, AddressMap]:
377 """Detect renames and moves between *base_syms* and *branch_syms*.
378
379 A **rename** is when a symbol's address disappears in *branch_syms* but
380 another address in the same file has the same ``body_hash``.
381
382 A **move** is when a symbol's address disappears and an address in a
383 *different file* has the same ``body_hash``.
384
385 Only non-trivial body hashes (symbols with non-empty bodies) are
386 considered, to reduce false positives from boilerplate methods.
387
388 Parameters
389 ----------
390 base_syms:
391 Symbols at the merge base.
392 branch_syms:
393 Symbols on the branch being inspected.
394
395 Returns
396 -------
397 renames : Manifest
398 Mapping ``{old_address: new_address}`` for renames in this branch.
399 moves : Manifest
400 Mapping ``{old_address: new_address}`` for moves in this branch.
401 """
402 branch_hash_map = _build_body_hash_map(branch_syms)
403 renames: AddressMap = {}
404 moves: AddressMap = {}
405
406 for old_addr, base_rec in base_syms.items():
407 if old_addr in branch_syms:
408 continue # Still present β€” not a rename/move candidate.
409 body_hash = base_rec["body_hash"]
410 # Skip trivial / empty bodies to avoid false positives.
411 if len(body_hash) < 10:
412 continue
413 candidates = [
414 a for a in branch_hash_map.get(body_hash, [])
415 if a not in base_syms # Must be new on this branch.
416 ]
417 if not candidates:
418 continue
419 # Prefer the candidate in the same file (rename) over one in another file (move).
420 old_file = old_addr.split("::")[0]
421 same_file = [a for a in candidates if a.split("::")[0] == old_file]
422 other_file = [a for a in candidates if a.split("::")[0] != old_file]
423 if same_file:
424 renames[old_addr] = same_file[0]
425 elif other_file:
426 moves[old_addr] = other_file[0]
427
428 return renames, moves
429
430
431 # ── delete_use detection ──────────────────────────────────────────────────────
432
433
434 def _find_delete_use_conflicts(
435 root: pathlib.Path,
436 base_manifest: Manifest,
437 ours_manifest: Manifest,
438 theirs_manifest: Manifest,
439 base_syms: SymbolMap,
440 ours_syms: SymbolMap,
441 theirs_syms: SymbolMap,
442 ) -> tuple[list[_MergeItem], bool, str | None]:
443 """Detect ``delete_use`` conflicts using forward call graphs.
444
445 A ``delete_use`` conflict occurs when:
446
447 * One branch **deletes** a symbol that existed at the base, AND
448 * The other branch adds **new call sites** for that same symbol.
449
450 This is the semantic equivalent of a use-after-free: one side removes a
451 function while the other side starts relying on it more.
452
453 Algorithm
454 ---------
455 1. Find all symbols deleted on *ours* that still exist on *theirs*.
456 2. Find all symbols deleted on *theirs* that still exist on *ours*.
457 3. Build forward call graphs for base, ours, and theirs.
458 4. For each deleted symbol, compare the call-site sets in base vs the
459 surviving branch. New call sites (in surviving branch but not in
460 base) = ``delete_use`` conflict.
461
462 Parameters
463 ----------
464 root:
465 Repository root.
466 base_manifest, ours_manifest, theirs_manifest:
467 Snapshot manifests for base, ours, and theirs.
468 base_syms, ours_syms, theirs_syms:
469 Already-loaded symbol maps for each snapshot.
470
471 Returns
472 -------
473 items : list[_MergeItem]
474 ``delete_use`` conflict items, one per deleted symbol with new callers.
475 call_graph_available : bool
476 ``True`` if the call graph analysis ran; ``False`` if it was skipped.
477 warning : str | None
478 Reason message when the call graph was unavailable.
479 """
480 from muse.plugins.code._callgraph import build_forward_graph
481
482 # Symbols deleted on ours (present in base, absent in ours, still in theirs).
483 deleted_on_ours = {
484 addr for addr in base_syms
485 if addr not in ours_syms and addr in theirs_syms
486 }
487 # Symbols deleted on theirs (present in base, absent in theirs, still in ours).
488 deleted_on_theirs = {
489 addr for addr in base_syms
490 if addr not in theirs_syms and addr in ours_syms
491 }
492
493 if not deleted_on_ours and not deleted_on_theirs:
494 return [], True, None # Nothing to check.
495
496 try:
497 base_fg = build_forward_graph(root, base_manifest)
498 ours_fg = build_forward_graph(root, ours_manifest)
499 theirs_fg = build_forward_graph(root, theirs_manifest)
500 except (OSError, KeyError, ValueError, AttributeError) as exc:
501 return [], False, f"delete_use analysis skipped β€” call graph unavailable: {exc}"
502
503 items: list[_MergeItem] = []
504
505 for addr in sorted(deleted_on_ours):
506 bare = addr.split("::")[-1]
507 base_callers = {a for a, callees in base_fg.items() if bare in callees}
508 theirs_callers = {a for a, callees in theirs_fg.items() if bare in callees}
509 new_callers = sorted(theirs_callers - base_callers)
510 if new_callers:
511 caller_preview = ", ".join(new_callers[:3])
512 if len(new_callers) > 3:
513 caller_preview += f" (+{len(new_callers) - 3} more)"
514 items.append(_MergeItem(
515 addr, "delete_use",
516 "deleted",
517 f"new caller(s): {caller_preview}",
518 "manual: keep the symbol or remove all new call sites on theirs",
519 ))
520
521 for addr in sorted(deleted_on_theirs):
522 bare = addr.split("::")[-1]
523 base_callers = {a for a, callees in base_fg.items() if bare in callees}
524 ours_callers = {a for a, callees in ours_fg.items() if bare in callees}
525 new_callers = sorted(ours_callers - base_callers)
526 if new_callers:
527 caller_preview = ", ".join(new_callers[:3])
528 if len(new_callers) > 3:
529 caller_preview += f" (+{len(new_callers) - 3} more)"
530 items.append(_MergeItem(
531 addr, "delete_use",
532 f"new caller(s): {caller_preview}",
533 "deleted",
534 "manual: keep the symbol or remove all new call sites on ours",
535 ))
536
537 return items, True, None
538
539
540 # ── dependency_conflict detection ─────────────────────────────────────────────
541
542
543 def _find_dependency_conflicts(
544 root: pathlib.Path,
545 ours_manifest: Manifest,
546 theirs_manifest: Manifest,
547 ours_changed: set[str],
548 theirs_changed: set[str],
549 ) -> tuple[list[_MergeItem], bool, str | None]:
550 """Detect ``dependency_conflict`` via the reverse call graph.
551
552 A ``dependency_conflict`` occurs when:
553
554 * Ours changed symbol A, AND
555 * Theirs changed symbol B, AND
556 * B transitively calls A (B depends on A's exact behaviour).
557
558 Even though A and B were changed on separate branches (no direct overlap),
559 B's semantics depend on A. If both changes merge cleanly at the text
560 level, the result may still be functionally broken.
561
562 Algorithm
563 ---------
564 1. Build the reverse call graph for *ours* (callee β†’ callers).
565 2. For each symbol changed on *ours* (``ours_changed``), find all
566 transitive callers in *ours* up to depth 3.
567 3. Intersect those callers with ``theirs_changed``. Any intersection
568 indicates that theirs modified something that (transitively) calls what
569 ours modified.
570
571 Parameters
572 ----------
573 root:
574 Repository root.
575 ours_manifest, theirs_manifest:
576 Snapshot manifests.
577 ours_changed:
578 Set of symbol addresses changed relative to base on *ours*.
579 theirs_changed:
580 Set of symbol addresses changed relative to base on *theirs*.
581
582 Returns
583 -------
584 items : list[_MergeItem]
585 Dependency conflict items.
586 available : bool
587 Whether the call graph was available.
588 warning : str | None
589 Reason if unavailable.
590 """
591 from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers
592
593 if not ours_changed or not theirs_changed:
594 return [], True, None
595
596 try:
597 reverse = build_reverse_graph(root, ours_manifest)
598 except (OSError, KeyError, ValueError, AttributeError) as exc:
599 return [], False, f"dependency_conflict analysis skipped β€” call graph unavailable: {exc}"
600
601 items: list[_MergeItem] = []
602 seen: set[tuple[str, str]] = set() # Deduplicate (ours_addr, theirs_addr) pairs.
603
604 for our_addr in sorted(ours_changed):
605 bare = our_addr.split("::")[-1]
606 callers_by_depth = transitive_callers(bare, reverse, max_depth=3)
607 all_callers = {a for lvl in callers_by_depth.values() for a in lvl}
608 for their_addr in sorted(theirs_changed):
609 if their_addr in all_callers:
610 key = (our_addr, their_addr)
611 if key not in seen:
612 seen.add(key)
613 items.append(_MergeItem(
614 our_addr, "dependency_conflict",
615 f"changed (caller: {their_addr})",
616 f"depends on {our_addr}",
617 "review: theirs depends on ours β€” test integration after merge",
618 ))
619
620 return items, True, None
621
622
623 # ── CLI registration ──────────────────────────────────────────────────────────
624
625
626 def register(
627 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
628 ) -> None:
629 """Register the ``plan-merge`` subcommand on *subparsers* (under ``muse coord``)."""
630 parser = subparsers.add_parser(
631 "plan-merge",
632 help="Dry-run semantic merge planning between two commits.",
633 description=__doc__,
634 formatter_class=argparse.RawDescriptionHelpFormatter,
635 )
636 parser.add_argument(
637 "ours_ref",
638 metavar="OURS",
639 help="Our commit/branch ref (e.g. HEAD, feature/billing, a1b2c3d4).",
640 )
641 parser.add_argument(
642 "theirs_ref",
643 metavar="THEIRS",
644 help="Their commit/branch ref (e.g. main, e5f6a7b8).",
645 )
646 parser.add_argument(
647 "--base",
648 default=None,
649 dest="base_ref",
650 metavar="BASE_REF",
651 help=(
652 "Override the merge base commit/branch. When absent, the base is "
653 "auto-computed as the Lowest Common Ancestor of OURS and THEIRS."
654 ),
655 )
656 parser.add_argument(
657 "--skip-call-graph",
658 action="store_true",
659 dest="skip_call_graph",
660 help=(
661 "Skip delete_use and dependency_conflict analysis (requires call "
662 "graph index). Use when the index is not built or for faster output."
663 ),
664 )
665 parser.add_argument(
666 "--format", "-f",
667 default="text",
668 dest="fmt",
669 choices=("text", "json"),
670 help="Output format: text (default) or json.",
671 )
672 parser.add_argument(
673 "--json",
674 action="store_const",
675 const="json",
676 dest="fmt",
677 help="Shorthand for --format json.",
678 )
679 parser.set_defaults(func=run)
680
681
682 # ── Command implementation ────────────────────────────────────────────────────
683
684
685 def run(args: argparse.Namespace) -> None:
686 """Perform a dry-run semantic merge plan between two commits.
687
688 Analysis passes
689 ---------------
690 **Pass 0 β€” Base commit resolution**
691 The merge base (LCA) of ``OURS`` and ``THEIRS`` is computed via
692 :func:`~muse.core.merge_engine.find_merge_base`. When ``--base`` is
693 supplied, that commit is used directly. When no common ancestor
694 exists (disconnected branches or first commits), analysis proceeds
695 without a base β€” conflict detection is less accurate.
696
697 **Pass 1 β€” Per-symbol three-way classification**
698 Every symbol address present in ours, theirs, or base is classified
699 using :func:`_classify_conflict` with full three-way semantics. This
700 identifies ``symbol_edit_overlap``, ``rename_edit`` (basic), and
701 marks unilateral deletions for Pass 3.
702
703 **Pass 2 β€” Cross-symbol rename and move detection**
704 Uses ``body_hash`` matching to find symbols that were renamed (same
705 file, different name) or moved (different file, same body). Items
706 already classified as ``symbol_edit_overlap`` may be upgraded to
707 ``rename_edit`` or ``move_edit`` when a matching rename/move is found
708 on either branch.
709
710 **Pass 3 β€” ``delete_use`` detection** (skipped if ``--skip-call-graph``)
711 Builds forward call graphs for base, ours, and theirs. For each
712 symbol deleted on one branch, checks whether the other branch gained
713 new callers. Skipped with a warning when the call graph is unavailable.
714
715 **Pass 4 β€” ``dependency_conflict`` detection** (skipped if ``--skip-call-graph``)
716 Builds the reverse call graph for ours and finds theirs-changed
717 symbols that transitively call ours-changed symbols. Confidence 0.75.
718
719 Security
720 --------
721 All user-supplied ref strings are passed through
722 :func:`~muse.core.store.resolve_commit_ref`, which strips glob
723 metacharacters before any path construction. Symbol addresses from
724 persisted records are passed through
725 :func:`~muse.core.validation.sanitize_display` before text output to
726 prevent ANSI injection. Error messages are emitted to *stdout* as compact
727 JSON when ``--format json`` is active, or to *stderr* with an ``❌`` prefix
728 otherwise β€” never in the reverse order.
729
730 Performance
731 -----------
732 Symbol trees are loaded via :func:`~muse.plugins.code._query.symbols_for_snapshot`
733 which uses the persistent symbol cache β€” three warm-cache loads for a
734 typical repo are sub-100 ms total. Pass 1 is O(V) where V is the union of
735 all symbol addresses. Pass 2 is O(V) with O(1) hash-map lookups.
736 Call graph construction (passes 3 and 4) is the most expensive step; use
737 ``--skip-call-graph`` to bypass it when speed matters more than completeness.
738
739 Output is compact JSON (no ``indent``); all JSON is single-line so it can
740 be piped directly to ``jq`` or agent parsers without special handling.
741
742 The command does not modify any files or repository state.
743
744 Args:
745 args: Parsed ``argparse.Namespace`` with attributes ``ours_ref``,
746 ``theirs_ref``, ``base_ref``, ``skip_call_graph``, and ``fmt``.
747
748 Exit codes:
749 0 β€” success (conflicts present is still success).
750 1 β€” a ref was not found or an unexpected error occurred.
751 """
752 t0 = time.monotonic()
753
754 ours_ref: str = args.ours_ref
755 theirs_ref: str = args.theirs_ref
756 base_ref: str | None = args.base_ref
757 skip_call_graph: bool = args.skip_call_graph
758 fmt: str = args.fmt
759 as_json = fmt == "json"
760
761 root = require_repo()
762 repo_id = read_repo_id(root)
763 branch = read_current_branch(root)
764
765 # ── Resolve OURS and THEIRS ───────────────────────────────────────────────
766 ours_commit = resolve_commit_ref(root, repo_id, branch, ours_ref)
767 if ours_commit is None:
768 _err(f"ref '{sanitize_display(ours_ref)}' not found", as_json, ExitCode.USER_ERROR)
769
770 theirs_commit = resolve_commit_ref(root, repo_id, branch, theirs_ref)
771 if theirs_commit is None:
772 _err(f"ref '{sanitize_display(theirs_ref)}' not found", as_json, ExitCode.USER_ERROR)
773
774 # ── Pass 0: Resolve base commit ───────────────────────────────────────────
775 base_commit_id: str | None = None
776 base_auto_computed = False
777 warnings: list[str] = []
778
779 if base_ref is not None:
780 base_commit = resolve_commit_ref(root, repo_id, branch, base_ref)
781 if base_commit is None:
782 _err(f"--base ref '{sanitize_display(base_ref)}' not found",
783 as_json, ExitCode.USER_ERROR)
784 base_commit_id = base_commit.commit_id
785 else:
786 base_auto_computed = True
787 try:
788 base_commit_id = find_merge_base(
789 root, ours_commit.commit_id, theirs_commit.commit_id
790 )
791 except Exception as exc: # noqa: BLE001 β€” merge-base walk can fail on deep/corrupt history
792 warnings.append(f"merge-base auto-detection failed: {exc}")
793 logger.debug("find_merge_base failed: %s", exc)
794
795 if base_commit_id is None:
796 warnings.append(
797 "no common ancestor found β€” analysis uses two-way diff (less accurate)"
798 )
799
800 # ── Load manifests ────────────────────────────────────────────────────────
801 ours_manifest = get_commit_snapshot_manifest(root, ours_commit.commit_id) or {}
802 theirs_manifest = get_commit_snapshot_manifest(root, theirs_commit.commit_id) or {}
803 base_manifest: Manifest = {}
804 if base_commit_id is not None:
805 base_manifest = get_commit_snapshot_manifest(root, base_commit_id) or {}
806
807 # ── Load symbol maps ──────────────────────────────────────────────────────
808 ours_syms: SymbolMap = {}
809 for _fp, tree in symbols_for_snapshot(root, ours_manifest).items():
810 ours_syms.update(tree)
811
812 theirs_syms: SymbolMap = {}
813 for _fp, tree in symbols_for_snapshot(root, theirs_manifest).items():
814 theirs_syms.update(tree)
815
816 base_syms: SymbolMap = {}
817 if base_manifest:
818 for _fp, tree in symbols_for_snapshot(root, base_manifest).items():
819 base_syms.update(tree)
820
821 # Collect all addresses across all three snapshots.
822 all_addrs = sorted(set(ours_syms) | set(theirs_syms) | set(base_syms))
823
824 # ── Pass 1: Per-symbol three-way classification ───────────────────────────
825 items: list[_MergeItem] = []
826 for addr in all_addrs:
827 item = _classify_conflict(
828 addr,
829 base=base_syms.get(addr),
830 ours=ours_syms.get(addr),
831 theirs=theirs_syms.get(addr),
832 )
833 items.append(item)
834
835 # Index items by address for Pass 2 upgrades.
836 item_map: MergeItemIndex = {item.address: item for item in items}
837
838 # ── Pass 2: Rename and move detection ─────────────────────────────────────
839 if base_syms:
840 ours_renames, ours_moves = _find_renames_and_moves(base_syms, ours_syms)
841 theirs_renames, theirs_moves = _find_renames_and_moves(base_syms, theirs_syms)
842
843 # Upgrade to rename_edit when one side renamed.
844 # This covers two cases:
845 # 1. Both sides still have fn_old at the same address (symbol_edit_overlap).
846 # 2. Ours deleted fn_old (renamed it away) while theirs modified fn_old (no_conflict
847 # from Pass 1 β€” delete vs modify). The rename detection reveals the true nature.
848 for old_addr, new_addr in ours_renames.items():
849 if old_addr in item_map:
850 item = item_map[old_addr]
851 if item.conflict_type == "symbol_edit_overlap":
852 item_map[old_addr] = _MergeItem(
853 old_addr, "rename_edit",
854 f"renamed to {new_addr.split('::')[-1]}",
855 item.theirs_change,
856 "manual: rename ours, rebase theirs onto new name",
857 )
858 elif (
859 item.conflict_type == "no_conflict"
860 and old_addr in theirs_syms
861 and old_addr in base_syms
862 and theirs_syms[old_addr]["content_id"] != base_syms[old_addr]["content_id"]
863 ):
864 # Ours renamed fn_old β†’ fn_new; theirs modified fn_old β†’ rename_edit.
865 their_change = _classify_change(base_syms[old_addr], theirs_syms[old_addr])
866 item_map[old_addr] = _MergeItem(
867 old_addr, "rename_edit",
868 f"renamed to {new_addr.split('::')[-1]}",
869 their_change,
870 "manual: rename ours, rebase theirs onto new name",
871 )
872 for old_addr, new_addr in theirs_renames.items():
873 if old_addr in item_map:
874 item = item_map[old_addr]
875 if item.conflict_type == "symbol_edit_overlap":
876 item_map[old_addr] = _MergeItem(
877 old_addr, "rename_edit",
878 item.ours_change,
879 f"renamed to {new_addr.split('::')[-1]}",
880 "manual: rebase ours onto theirs' rename",
881 )
882 elif (
883 item.conflict_type == "no_conflict"
884 and old_addr in ours_syms
885 and old_addr in base_syms
886 and ours_syms[old_addr]["content_id"] != base_syms[old_addr]["content_id"]
887 ):
888 # Theirs renamed fn_old β†’ fn_new; ours modified fn_old β†’ rename_edit.
889 our_change = _classify_change(base_syms[old_addr], ours_syms[old_addr])
890 item_map[old_addr] = _MergeItem(
891 old_addr, "rename_edit",
892 our_change,
893 f"renamed to {new_addr.split('::')[-1]}",
894 "manual: rebase ours onto theirs' rename",
895 )
896
897 # Add move_edit items (new items, not upgrades).
898 for old_addr, new_addr in ours_moves.items():
899 # Only a move_edit if theirs also modified the original address.
900 if old_addr in theirs_syms and old_addr in base_syms:
901 their_change = _classify_change(base_syms[old_addr], theirs_syms[old_addr])
902 if their_change != "unchanged":
903 old_file = old_addr.split("::")[0]
904 new_file = new_addr.split("::")[0]
905 # Remove the per-symbol no_conflict entry and replace with move_edit.
906 item_map[old_addr] = _MergeItem(
907 old_addr, "move_edit",
908 f"moved to {new_file}",
909 their_change,
910 f"manual: apply theirs' edit to the new location {new_addr}",
911 )
912
913 for old_addr, new_addr in theirs_moves.items():
914 if old_addr in ours_syms and old_addr in base_syms:
915 our_change = _classify_change(base_syms[old_addr], ours_syms[old_addr])
916 if our_change != "unchanged":
917 new_file = new_addr.split("::")[0]
918 item_map[old_addr] = _MergeItem(
919 old_addr, "move_edit",
920 our_change,
921 f"moved to {new_file}",
922 f"manual: apply ours' edit to the new location {new_addr}",
923 )
924
925 # Reconstruct items list from the (possibly upgraded) map.
926 items = [item_map[addr] for addr in all_addrs]
927
928 # ── Pass 3: delete_use detection ──────────────────────────────────────────
929 call_graph_available = False
930 call_graph_skipped = skip_call_graph
931
932 if not skip_call_graph and base_syms:
933 du_items, cg_ok, cg_warn = _find_delete_use_conflicts(
934 root, base_manifest, ours_manifest, theirs_manifest,
935 base_syms, ours_syms, theirs_syms,
936 )
937 call_graph_available = cg_ok
938 if cg_warn:
939 warnings.append(cg_warn)
940 items.extend(du_items)
941
942 # ── Pass 4: dependency_conflict detection ──────────────────────────────────
943 if not skip_call_graph and base_syms:
944 # Compute changed-symbol sets relative to base.
945 ours_changed = {
946 addr for addr in ours_syms
947 if addr in base_syms
948 and ours_syms[addr]["content_id"] != base_syms[addr]["content_id"]
949 }
950 theirs_changed = {
951 addr for addr in theirs_syms
952 if addr in base_syms
953 and theirs_syms[addr]["content_id"] != base_syms[addr]["content_id"]
954 }
955 dc_items, dc_ok, dc_warn = _find_dependency_conflicts(
956 root, ours_manifest, theirs_manifest, ours_changed, theirs_changed,
957 )
958 if not call_graph_available and dc_ok:
959 call_graph_available = True
960 if dc_warn and dc_warn not in warnings:
961 warnings.append(dc_warn)
962 items.extend(dc_items)
963
964 conflicts = [i for i in items if i.conflict_type != "no_conflict"]
965 clean = [i for i in items if i.conflict_type == "no_conflict"]
966 elapsed = round(time.monotonic() - t0, 4)
967
968 # Build per-type breakdown (useful for agent decision logic).
969 conflicts_by_type: ConflictTypeCount = {}
970 for c in conflicts:
971 conflicts_by_type[c.conflict_type] = conflicts_by_type.get(c.conflict_type, 0) + 1
972
973 # ── JSON output ───────────────────────────────────────────────────────────
974 if as_json:
975 print(json.dumps({
976 "schema_version": __version__,
977 "ours": ours_commit.commit_id,
978 "theirs": theirs_commit.commit_id,
979 "base": base_commit_id,
980 "base_auto_computed": base_auto_computed,
981 "call_graph_available": call_graph_available,
982 "call_graph_skipped": call_graph_skipped,
983 "warnings": warnings,
984 "total_symbols": len(all_addrs),
985 "conflicts": len(conflicts),
986 "clean": len(clean),
987 "conflicts_by_type": conflicts_by_type,
988 "items": [i.to_dict() for i in conflicts],
989 "elapsed_seconds": elapsed,
990 }))
991 return
992
993 # ── Text output ───────────────────────────────────────────────────────────
994 ours_short = ours_commit.commit_id[:8]
995 theirs_short = theirs_commit.commit_id[:8]
996 base_short = base_commit_id[:8] if base_commit_id else "none"
997 print(
998 f"\nSemantic merge plan β€” {ours_short} ← (merging) {theirs_short}"
999 f" [base: {base_short}]"
1000 )
1001 print("─" * 62)
1002
1003 if warnings:
1004 for w in warnings:
1005 print(f"\n ⚠ Note: {w}")
1006
1007 if not conflicts:
1008 print(
1009 f"\n βœ… No conflicts detected"
1010 f" ({len(clean)} symbol(s) auto-merge safely)"
1011 )
1012 else:
1013 _CONFLICT_ICONS = {
1014 "symbol_edit_overlap": "πŸ”΄",
1015 "rename_edit": "⚠️ ",
1016 "move_edit": "⚠️ ",
1017 "delete_use": "πŸ”΄",
1018 "dependency_conflict": "🟑",
1019 }
1020 for item in sorted(conflicts, key=lambda i: i.conflict_type):
1021 icon = _CONFLICT_ICONS.get(item.conflict_type, "⚠️ ")
1022 addr = sanitize_display(item.address)
1023 print(f"\n{icon} {item.conflict_type:<24} {addr}")
1024 print(f" ours: {sanitize_display(item.ours_change)}")
1025 print(f" theirs: {sanitize_display(item.theirs_change)}")
1026 print(f" β†’ {sanitize_display(item.recommendation)}")
1027
1028 by_type: ConflictTypeCount = {}
1029 for c in conflicts:
1030 by_type[c.conflict_type] = by_type.get(c.conflict_type, 0) + 1
1031 summary = ", ".join(f"{n} {t}" for t, n in sorted(by_type.items()))
1032 print(f"\n Summary: {len(conflicts)} conflict(s) [{summary}], {len(clean)} clean")
1033 print(" Run 'muse coord reconcile' for a detailed integration strategy.")
1034
1035 print(f"\n ({elapsed:.3f}s)")