plan_merge.py
python
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