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