merge.py
python
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47
feat(merge): add --explain flag with per-path decision trac…
Sonnet 4.6
minor
⚠ breaking
21 hours ago
| 1 | """``muse merge`` — three-way merge a branch into the current branch. |
| 2 | |
| 3 | Algorithm |
| 4 | --------- |
| 5 | 1. Find the merge base (LCA of HEAD and the target branch). |
| 6 | 2. Delegate conflict detection and manifest reconciliation to the domain plugin. |
| 7 | 3. If clean → apply merged manifest, write new commit, advance HEAD. |
| 8 | 4. If conflicts → write conflict markers to the working tree, write |
| 9 | ``.muse/MERGE_STATE.json``, exit non-zero. |
| 10 | |
| 11 | Usage:: |
| 12 | |
| 13 | muse merge <branch> — three-way merge into current branch |
| 14 | muse merge <branch> --dry-run — simulate without writing anything |
| 15 | muse merge <branch> --strategy=ours — auto-resolve conflicts keeping ours |
| 16 | muse merge --abort — cancel in-progress merge |
| 17 | |
| 18 | JSON output (``--format json`` or ``--json``):: |
| 19 | |
| 20 | { |
| 21 | "status": "merged|fast_forward|conflict|up_to_date", |
| 22 | "commit_id": "<sha256> | null", |
| 23 | "branch": "<source-branch>", |
| 24 | "current_branch": "<target-branch>", |
| 25 | "base_commit_id": "<sha256> | null", |
| 26 | "conflicts": ["<path>", ...], |
| 27 | "files_changed": {"added": N, "modified": N, "deleted": N}, |
| 28 | "semver_impact": "MAJOR|MINOR|PATCH|", |
| 29 | "strategy": "ours|theirs|recursive|null", |
| 30 | "dry_run": false, |
| 31 | "duration_ms": 12.4, |
| 32 | "exit_code": 0 |
| 33 | } |
| 34 | |
| 35 | JSON error schema (usage/internal errors):: |
| 36 | |
| 37 | { |
| 38 | "status": "error", |
| 39 | "error": "<human-readable message>", |
| 40 | "exit_code": <int> |
| 41 | } |
| 42 | |
| 43 | When ``--json`` is active all errors go to stdout as JSON — no prose on |
| 44 | stderr. Agents should parse stdout and check ``exit_code``. |
| 45 | |
| 46 | Exit codes:: |
| 47 | |
| 48 | 0 — success (merged, fast-forward, up-to-date, dry-run) |
| 49 | 1 — conflict detected (use ``muse conflicts`` / ``muse checkout --ours/--theirs``) |
| 50 | 1 — invalid arguments (missing branch, bad format) |
| 51 | 3 — internal error (unreadable commit or snapshot) |
| 52 | """ |
| 53 | |
| 54 | import argparse |
| 55 | import datetime |
| 56 | import json |
| 57 | import logging |
| 58 | import os |
| 59 | import pathlib |
| 60 | import sys |
| 61 | import time |
| 62 | from typing import TypedDict |
| 63 | |
| 64 | from muse.core.types import short_id |
| 65 | from muse.core.paths import merge_state_path as _merge_state_path |
| 66 | from muse.core.errors import ExitCode |
| 67 | from muse.core.merge_engine import ( |
| 68 | STRATEGY_MAP, |
| 69 | apply_merge, |
| 70 | detect_conflicts, |
| 71 | diff_snapshots, |
| 72 | find_merge_base, |
| 73 | read_merge_state, |
| 74 | run_merge, |
| 75 | write_merge_state, |
| 76 | ) |
| 77 | from muse.core.repo import require_repo |
| 78 | from muse.cli.config import get_config_value |
| 79 | from muse.core.validation import sanitize_provenance |
| 80 | from muse.core.terminal import use_color |
| 81 | from muse.core.harmony import auto_apply as harmony_auto_apply |
| 82 | from muse.core.ids import hash_commit, hash_snapshot |
| 83 | from muse.core.snapshot import directories_from_manifest |
| 84 | from muse.core.types import Manifest |
| 85 | from muse.core.refs import ( |
| 86 | RefConflictError, |
| 87 | get_head_commit_id, |
| 88 | read_current_branch, |
| 89 | resolve_any_ref, |
| 90 | write_branch_ref, |
| 91 | ) |
| 92 | from muse.core.commits import ( |
| 93 | CommitRecord, |
| 94 | read_commit, |
| 95 | write_commit, |
| 96 | ) |
| 97 | from muse.core.snapshots import ( |
| 98 | SnapshotRecord, |
| 99 | get_head_snapshot_manifest, |
| 100 | read_snapshot, |
| 101 | write_snapshot, |
| 102 | ) |
| 103 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 104 | from muse.core.reflog import append_reflog |
| 105 | from muse.core.validation import sanitize_display, validate_branch_name |
| 106 | from muse.cli.config import get_protected_branches, is_branch_protected |
| 107 | from muse.core.workdir import apply_manifest |
| 108 | from muse.core.object_store import restore_object |
| 109 | from muse.core.merge_debug import merge_debug_log, merge_debug_manifest_summary |
| 110 | from muse.core.timing import start_timer |
| 111 | from muse.plugins.code.stage import read_stage, write_stage |
| 112 | from muse.cli.guard import require_clean_workdir |
| 113 | from muse.cli.commands.checkout import _apply_autoshelf |
| 114 | from muse.cli.commands.shelf import _shelf_push_programmatic |
| 115 | from muse.domain import AddressedMergePlugin, ConflictDict, DomainOp, MergeResult, SnapshotManifest |
| 116 | from muse.plugins.registry import read_domain, resolve_plugin |
| 117 | |
| 118 | logger = logging.getLogger(__name__) |
| 119 | |
| 120 | # --------------------------------------------------------------------------- |
| 121 | # Wire-format TypedDicts |
| 122 | # --------------------------------------------------------------------------- |
| 123 | |
| 124 | class _SymbolConflictDict(TypedDict): |
| 125 | path: str |
| 126 | symbol: str |
| 127 | ours: str |
| 128 | theirs: str |
| 129 | |
| 130 | type _FilesChangedMap = dict[str, int] |
| 131 | |
| 132 | class _MergeResultDict(TypedDict): |
| 133 | """Merge execution summary — mirrored in ProposalResponse.merge_result.""" |
| 134 | status: str |
| 135 | commit_id: str | None |
| 136 | strategy: str | None |
| 137 | on_conflict: str | None |
| 138 | history: str | None |
| 139 | conflicts: list[str] |
| 140 | files_changed: _FilesChangedMap |
| 141 | semver_impact: str |
| 142 | |
| 143 | class _MergeJsonBase(EnvelopeJson): |
| 144 | """Common JSON envelope fields for all ``muse merge --json`` outcome paths. |
| 145 | |
| 146 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 147 | """ |
| 148 | status: str # "merged" | "fast_forward" | "conflict" | "up_to_date" |
| 149 | commit_id: str | None |
| 150 | branch: str |
| 151 | current_branch: str |
| 152 | base_commit_id: str | None |
| 153 | conflicts: list[str] |
| 154 | files_changed: _FilesChangedMap |
| 155 | semver_impact: str |
| 156 | strategy: str | None |
| 157 | on_conflict: str | None |
| 158 | history: str | None |
| 159 | dry_run: bool |
| 160 | merge_result: _MergeResultDict |
| 161 | |
| 162 | class _MergeJson(_MergeJsonBase, total=False): |
| 163 | """Extended merge JSON — adds optional conflict-detail fields.""" |
| 164 | conflict_records: list[ConflictDict] |
| 165 | symbol_conflicts: list[_SymbolConflictDict] |
| 166 | |
| 167 | class _MergeErrorJson(EnvelopeJson): |
| 168 | """Error payload for ``muse merge --json`` on usage/internal errors.""" |
| 169 | status: str # "error" |
| 170 | error: str |
| 171 | |
| 172 | def _emit_error(json_out: bool, msg: str, code: "ExitCode", elapsed: float) -> None: |
| 173 | """Print an error and raise SystemExit. Never returns. |
| 174 | |
| 175 | In ``--json`` mode the error goes to stdout as a JSON payload so agents |
| 176 | always get parseable output. In text mode it goes to stderr. |
| 177 | """ |
| 178 | if json_out: |
| 179 | print(json.dumps(_MergeErrorJson( |
| 180 | **make_envelope(elapsed, exit_code=int(code)), |
| 181 | status="error", |
| 182 | error=msg, |
| 183 | ))) |
| 184 | else: |
| 185 | print(f"❌ {msg}", file=sys.stderr) |
| 186 | raise SystemExit(code) |
| 187 | |
| 188 | # --------------------------------------------------------------------------- |
| 189 | # Colour helpers — respect NO_COLOR and TERM=dumb |
| 190 | # --------------------------------------------------------------------------- |
| 191 | |
| 192 | _RESET = "\033[0m" |
| 193 | _BOLD = "\033[1m" |
| 194 | _DIM = "\033[2m" |
| 195 | _GREEN = "\033[32m" |
| 196 | _RED = "\033[31m" |
| 197 | _YELLOW = "\033[33m" |
| 198 | _CYAN = "\033[36m" |
| 199 | |
| 200 | def _c(text: str, *codes: str) -> str: |
| 201 | """Wrap *text* in ANSI escape *codes* only when writing to a colour-capable TTY.""" |
| 202 | if not use_color(): |
| 203 | return text |
| 204 | return "".join(codes) + text + _RESET |
| 205 | |
| 206 | def _diff_stats( |
| 207 | old: Manifest, |
| 208 | new: Manifest, |
| 209 | ) -> tuple[int, int, int]: |
| 210 | """Return (added, modified, deleted) file counts between two manifests.""" |
| 211 | added = sum(1 for k in new if k not in old) |
| 212 | deleted = sum(1 for k in old if k not in new) |
| 213 | modified = sum(1 for k in new if k in old and old[k] != new[k]) |
| 214 | return added, modified, deleted |
| 215 | |
| 216 | def _print_file_stats( |
| 217 | added: int, |
| 218 | modified: int, |
| 219 | deleted: int, |
| 220 | ) -> None: |
| 221 | """Emit the 'N files changed (A added, M modified, D deleted)' summary line.""" |
| 222 | total = added + modified + deleted |
| 223 | if total == 0: |
| 224 | return |
| 225 | files_word = "file" if total == 1 else "files" |
| 226 | parts: list[str] = [] |
| 227 | if added: |
| 228 | parts.append(_c(f"{added} added", _GREEN)) |
| 229 | if modified: |
| 230 | parts.append(f"{modified} modified") |
| 231 | if deleted: |
| 232 | parts.append(_c(f"{deleted} deleted", _RED)) |
| 233 | detail = ", ".join(parts) |
| 234 | print(f" {_c(str(total), _BOLD)} {files_word} changed ({detail})") |
| 235 | |
| 236 | def _semver_from_op_log(op_log: list[DomainOp]) -> str: |
| 237 | """Infer a proposed semver bump from a structured merge operation log. |
| 238 | |
| 239 | This is Muse-unique: because Muse tracks named symbols across time (not |
| 240 | just line changes), it can tell you whether a merge would introduce a |
| 241 | breaking API change (MAJOR), a new feature (MINOR), or a fix (PATCH). |
| 242 | |
| 243 | Heuristic (conservative — errs toward higher impact): |
| 244 | - Any ``delete`` or ``rename`` on a public symbol → MAJOR |
| 245 | - Any ``add`` → MINOR |
| 246 | - Any ``modify`` → PATCH |
| 247 | - Empty log → "" (cannot determine) |
| 248 | |
| 249 | Args: |
| 250 | op_log: List of operation dicts from ``MergeResult.op_log``. |
| 251 | Each dict has at least ``"op"`` (add/modify/delete/rename) |
| 252 | and ``"symbol"`` keys. Public symbols lack a ``_`` prefix. |
| 253 | |
| 254 | Returns: |
| 255 | "MAJOR", "MINOR", "PATCH", or "" when the log is empty. |
| 256 | """ |
| 257 | if not op_log: |
| 258 | return "" |
| 259 | impact = "PATCH" |
| 260 | for op in op_log: |
| 261 | op_kind = str(op.get("op", "")).lower() |
| 262 | symbol = str(op.get("symbol", "")) |
| 263 | is_public = symbol and not symbol.startswith("_") |
| 264 | if op_kind in ("delete", "rename") and is_public: |
| 265 | return "MAJOR" |
| 266 | if op_kind == "add" and is_public and impact != "MAJOR": |
| 267 | impact = "MINOR" |
| 268 | return impact |
| 269 | |
| 270 | def _run_abort(json_out: bool) -> None: |
| 271 | """Abort an in-progress merge and restore the pre-merge working tree.""" |
| 272 | root = require_repo() |
| 273 | |
| 274 | merge_state = read_merge_state(root) |
| 275 | if merge_state is None: |
| 276 | msg = "No merge in progress." |
| 277 | if json_out: |
| 278 | print(json.dumps({"error": "no_merge_in_progress", "message": msg})) |
| 279 | else: |
| 280 | print(f"❌ {msg}", file=sys.stderr) |
| 281 | raise SystemExit(ExitCode.USER_ERROR) |
| 282 | |
| 283 | ours_commit_id = merge_state.ours_commit or "" |
| 284 | if not ours_commit_id: |
| 285 | print( |
| 286 | "❌ MERGE_STATE has no ours_commit — cannot restore working tree.", |
| 287 | file=sys.stderr, |
| 288 | ) |
| 289 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 290 | |
| 291 | ours_commit = read_commit(root, ours_commit_id) |
| 292 | if ours_commit is None: |
| 293 | print( |
| 294 | f"❌ Cannot restore: pre-merge commit '{ours_commit_id}' not found.", |
| 295 | file=sys.stderr, |
| 296 | ) |
| 297 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 298 | |
| 299 | snapshot_id: str | None = ours_commit.snapshot_id or None |
| 300 | if snapshot_id: |
| 301 | snap = read_snapshot(root, snapshot_id) |
| 302 | if snap is not None: |
| 303 | theirs_manifest: Manifest = {} |
| 304 | if merge_state.theirs_commit: |
| 305 | theirs_commit_rec = read_commit(root, merge_state.theirs_commit) |
| 306 | if theirs_commit_rec: |
| 307 | theirs_snap_rec = read_snapshot(root, theirs_commit_rec.snapshot_id) |
| 308 | if theirs_snap_rec: |
| 309 | theirs_manifest = dict(theirs_snap_rec.manifest) |
| 310 | apply_manifest(root, {**snap.manifest, **theirs_manifest}, snap.manifest) |
| 311 | |
| 312 | # Clear the stage — abort means "undo everything back to the pre-merge state", |
| 313 | # including any files staged during conflict resolution. |
| 314 | write_stage(root, {}) |
| 315 | |
| 316 | _merge_state_path(root).unlink(missing_ok=True) |
| 317 | |
| 318 | if json_out: |
| 319 | print(json.dumps({ |
| 320 | "status": "aborted", |
| 321 | "restored_to": ours_commit_id, |
| 322 | })) |
| 323 | else: |
| 324 | print(f"Merge aborted. Working tree restored to {ours_commit_id}.") |
| 325 | |
| 326 | def _build_explain_trace( |
| 327 | requested_strategy: str | None, |
| 328 | on_conflict: str | None, |
| 329 | history: str | None, |
| 330 | diff_unit: str, |
| 331 | resolution: str, |
| 332 | base_commit_id: str | None, |
| 333 | base_manifest: dict, |
| 334 | ours_manifest: dict, |
| 335 | theirs_manifest: dict, |
| 336 | result_manifest: dict, |
| 337 | pre_harmony_conflicts: list[str], |
| 338 | harmony_decisions: dict, |
| 339 | harmony_was_run: bool, |
| 340 | ) -> dict: |
| 341 | """Build the explain trace dict from merge inputs and outcomes.""" |
| 342 | from collections import Counter |
| 343 | |
| 344 | # Normalise symbol-level conflict addresses (e.g. "file.py::Sym") to file paths. |
| 345 | pre_harmony_file_conflicts: set[str] = { |
| 346 | p.split("::")[0] if "::" in p else p for p in pre_harmony_conflicts |
| 347 | } |
| 348 | |
| 349 | all_paths = sorted( |
| 350 | set(base_manifest) | set(ours_manifest) | set(theirs_manifest) | set(result_manifest) |
| 351 | ) |
| 352 | |
| 353 | per_path = [] |
| 354 | for path in all_paths: |
| 355 | base_id = base_manifest.get(path) |
| 356 | ours_id = ours_manifest.get(path) |
| 357 | theirs_id = theirs_manifest.get(path) |
| 358 | |
| 359 | ours_changed = ours_id != base_id |
| 360 | theirs_changed = theirs_id != base_id |
| 361 | |
| 362 | harmony_info = harmony_decisions.get(path, {}) |
| 363 | harmony_result_str = harmony_info.get("result", "not_applicable") |
| 364 | harmony_pattern_id = harmony_info.get("pattern_id") |
| 365 | harmony_checked = path in pre_harmony_file_conflicts and harmony_was_run |
| 366 | |
| 367 | if harmony_info.get("result") == "auto_resolved": |
| 368 | decision = "harmony_auto_resolved" |
| 369 | reason = "harmony auto-resolved via saved pattern" |
| 370 | elif path in pre_harmony_file_conflicts: |
| 371 | decision = "conflict" |
| 372 | reason = "both sides modified; content diverged" |
| 373 | if harmony_was_run: |
| 374 | harmony_result_str = harmony_info.get("result", "no_pattern") |
| 375 | elif not ours_changed and not theirs_changed: |
| 376 | decision = "no_change" |
| 377 | reason = "identical on both sides" |
| 378 | elif ours_changed and not theirs_changed: |
| 379 | decision = "take_ours_only" |
| 380 | reason = "only ours changed" |
| 381 | elif not ours_changed and theirs_changed: |
| 382 | decision = "take_theirs_only" |
| 383 | reason = "only theirs changed" |
| 384 | elif ours_id == theirs_id: |
| 385 | decision = "convergent" |
| 386 | reason = "both sides independently arrived at same content" |
| 387 | elif resolution == "prefer_theirs": |
| 388 | decision = "overlay_auto_resolved" |
| 389 | reason = "overlay strategy: theirs wins" |
| 390 | elif resolution == "prefer_ours": |
| 391 | decision = "take_ours_only" |
| 392 | reason = "prefer-ours strategy: ours wins" |
| 393 | else: |
| 394 | decision = "independence_merged" |
| 395 | reason = "symbol-level independence confirmed; text merge applied" |
| 396 | |
| 397 | per_path.append({ |
| 398 | "path": path, |
| 399 | "ours_changed": ours_changed, |
| 400 | "theirs_changed": theirs_changed, |
| 401 | "ours_id": ours_id, |
| 402 | "theirs_id": theirs_id, |
| 403 | "base_id": base_id, |
| 404 | "decision": decision, |
| 405 | "reason": reason, |
| 406 | "harmony_checked": harmony_checked, |
| 407 | "harmony_result": harmony_result_str if harmony_checked else "not_applicable", |
| 408 | "harmony_pattern_id": harmony_pattern_id, |
| 409 | "attributes_rule": None, |
| 410 | "independence_merge_attempted": decision == "independence_merged", |
| 411 | }) |
| 412 | |
| 413 | counts = Counter(e["decision"] for e in per_path) |
| 414 | return { |
| 415 | "strategy_routing": { |
| 416 | "requested_strategy": requested_strategy, |
| 417 | "on_conflict": on_conflict, |
| 418 | "history": history, |
| 419 | "resolved_diff_unit": diff_unit, |
| 420 | "resolved_resolution": resolution, |
| 421 | "case": 4, |
| 422 | }, |
| 423 | "merge_base_commit_id": base_commit_id, |
| 424 | "per_path": per_path, |
| 425 | "summary": { |
| 426 | "total_paths": len(per_path), |
| 427 | "clean_no_change": counts.get("no_change", 0), |
| 428 | "take_ours_only": counts.get("take_ours_only", 0), |
| 429 | "take_theirs_only": counts.get("take_theirs_only", 0), |
| 430 | "convergent": counts.get("convergent", 0), |
| 431 | "harmony_auto_resolved": counts.get("harmony_auto_resolved", 0), |
| 432 | "attributes_auto_resolved": counts.get("attributes_auto_resolved", 0), |
| 433 | "independence_merged": counts.get("independence_merged", 0), |
| 434 | "conflicts": counts.get("conflict", 0), |
| 435 | }, |
| 436 | } |
| 437 | |
| 438 | |
| 439 | def _print_explain_trace(trace: dict) -> None: |
| 440 | """Print the explain trace in human-readable format to stdout.""" |
| 441 | sr = trace["strategy_routing"] |
| 442 | diff_unit = sr.get("resolved_diff_unit", "") |
| 443 | resolution = sr.get("resolved_resolution", "") |
| 444 | strategy_name = sr.get("requested_strategy") or "recursive" |
| 445 | base_cid = trace.get("merge_base_commit_id") or "(none)" |
| 446 | short_base = base_cid[:20] if base_cid.startswith("sha256:") else base_cid |
| 447 | |
| 448 | print(f"\n Merge base: {short_base}") |
| 449 | print(f" Strategy: {strategy_name} ({diff_unit} + {resolution}) → Case {sr.get('case', '?')}") |
| 450 | print() |
| 451 | |
| 452 | _DECISION_LABEL = { |
| 453 | "no_change": "no-change", |
| 454 | "take_ours_only": "take-ours", |
| 455 | "take_theirs_only": "take-theirs", |
| 456 | "convergent": "convergent", |
| 457 | "harmony_auto_resolved": "harmony", |
| 458 | "attributes_auto_resolved": "attributes", |
| 459 | "independence_merged": "independence", |
| 460 | "conflict": "CONFLICT", |
| 461 | "overlay_auto_resolved": "overlay", |
| 462 | } |
| 463 | |
| 464 | for entry in trace["per_path"]: |
| 465 | path = sanitize_display(entry["path"]) |
| 466 | label = _DECISION_LABEL.get(entry["decision"], entry["decision"]) |
| 467 | reason = entry.get("reason", "") |
| 468 | pattern_id = entry.get("harmony_pattern_id") |
| 469 | extra = "" |
| 470 | if pattern_id: |
| 471 | extra = f" (pattern {pattern_id[:12]})" |
| 472 | print(f" {path:<30} {label:<14} {reason}{extra}") |
| 473 | |
| 474 | s = trace["summary"] |
| 475 | parts = [] |
| 476 | if s.get("conflicts"): |
| 477 | parts.append(f"{s['conflicts']} conflict{'s' if s['conflicts'] != 1 else ''}") |
| 478 | if s.get("harmony_auto_resolved"): |
| 479 | parts.append(f"{s['harmony_auto_resolved']} harmony auto-resolved") |
| 480 | if s.get("convergent"): |
| 481 | parts.append(f"{s['convergent']} convergent") |
| 482 | if s.get("clean_no_change"): |
| 483 | parts.append(f"{s['clean_no_change']} no-change") |
| 484 | print() |
| 485 | print(" " + " · ".join(parts) if parts else " (no changes)") |
| 486 | |
| 487 | |
| 488 | def _run_merge( |
| 489 | root: pathlib.Path, |
| 490 | branch: str, |
| 491 | no_ff: bool, |
| 492 | message: str | None, |
| 493 | harmony_autoupdate: bool, |
| 494 | force: bool, |
| 495 | dry_run: bool, |
| 496 | strategy: str | None, |
| 497 | json_out: bool, |
| 498 | on_conflict: str | None = None, |
| 499 | history: str | None = None, |
| 500 | explain: bool = False, |
| 501 | ) -> None: |
| 502 | """Execute the merge — called by :func:`run` after autoshelf setup.""" |
| 503 | elapsed = start_timer() |
| 504 | |
| 505 | # Dry-run never touches the working tree; skip the clean-workdir check. |
| 506 | if not dry_run: |
| 507 | require_clean_workdir(root, "merge", force=force, json_out=json_out) |
| 508 | current_branch = read_current_branch(root) |
| 509 | domain = read_domain(root) |
| 510 | plugin = resolve_plugin(root) |
| 511 | |
| 512 | protected = get_protected_branches(root) |
| 513 | if is_branch_protected(current_branch, protected): |
| 514 | _emit_error( |
| 515 | json_out, |
| 516 | f"Branch '{sanitize_display(current_branch)}' is protected — merge directly into it is not allowed. " |
| 517 | "Merge into an integration branch (e.g. dev) and promote via the standard flow.", |
| 518 | ExitCode.USER_ERROR, |
| 519 | elapsed, |
| 520 | ) |
| 521 | |
| 522 | if branch == current_branch: |
| 523 | _emit_error(json_out, "Cannot merge a branch into itself.", ExitCode.USER_ERROR, elapsed) |
| 524 | |
| 525 | ours_commit_id = get_head_commit_id(root, current_branch) |
| 526 | theirs_commit_id = resolve_any_ref(root, branch) |
| 527 | |
| 528 | if theirs_commit_id is None: |
| 529 | _emit_error(json_out, f"Branch '{sanitize_display(branch)}' has no commits.", ExitCode.USER_ERROR, elapsed) |
| 530 | |
| 531 | if ours_commit_id is None: |
| 532 | _emit_error(json_out, "Current branch has no commits.", ExitCode.USER_ERROR, elapsed) |
| 533 | |
| 534 | base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id) |
| 535 | |
| 536 | # Capture the user-visible strategy name before any normalization. |
| 537 | # Cases 1 and 2 emit JSON before the STRATEGY_MAP routing block, so |
| 538 | # reported_strategy must be set here. |
| 539 | reported_strategy = strategy |
| 540 | |
| 541 | # ------------------------------------------------------------------- |
| 542 | # Case 1: already up to date |
| 543 | # ------------------------------------------------------------------- |
| 544 | if base_commit_id == theirs_commit_id: |
| 545 | if json_out: |
| 546 | print(json.dumps(_MergeJsonBase( |
| 547 | **make_envelope(elapsed), |
| 548 | status="up_to_date", |
| 549 | commit_id=ours_commit_id, |
| 550 | branch=branch, |
| 551 | current_branch=current_branch, |
| 552 | base_commit_id=base_commit_id, |
| 553 | conflicts=[], |
| 554 | files_changed={"added": 0, "modified": 0, "deleted": 0}, |
| 555 | semver_impact="", |
| 556 | strategy=reported_strategy, |
| 557 | on_conflict=on_conflict, |
| 558 | history=history, |
| 559 | dry_run=dry_run, |
| 560 | merge_result=_MergeResultDict( |
| 561 | status="up_to_date", |
| 562 | commit_id=ours_commit_id, |
| 563 | strategy=reported_strategy, |
| 564 | on_conflict=on_conflict, |
| 565 | history=history, |
| 566 | conflicts=[], |
| 567 | files_changed={"added": 0, "modified": 0, "deleted": 0}, |
| 568 | semver_impact="", |
| 569 | ), |
| 570 | ))) |
| 571 | else: |
| 572 | print("Already up to date.") |
| 573 | return |
| 574 | |
| 575 | # ------------------------------------------------------------------- |
| 576 | # Case 2: fast-forward |
| 577 | # ------------------------------------------------------------------- |
| 578 | if base_commit_id == ours_commit_id and not no_ff: |
| 579 | theirs_commit = read_commit(root, theirs_commit_id) |
| 580 | if theirs_commit is None: |
| 581 | print( |
| 582 | f"❌ Cannot read commit {theirs_commit_id} for branch " |
| 583 | f"'{sanitize_display(branch)}' — aborting to prevent data loss.", |
| 584 | file=sys.stderr, |
| 585 | ) |
| 586 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 587 | ff_snap = read_snapshot(root, theirs_commit.snapshot_id) |
| 588 | if ff_snap is None: |
| 589 | print( |
| 590 | f"❌ Cannot read snapshot {theirs_commit.snapshot_id} for branch " |
| 591 | f"'{sanitize_display(branch)}' — aborting to prevent data loss.", |
| 592 | file=sys.stderr, |
| 593 | ) |
| 594 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 595 | ff_manifest: Manifest = ff_snap.manifest |
| 596 | |
| 597 | ours_commit_rec = read_commit(root, ours_commit_id) |
| 598 | ours_ff_manifest: Manifest = {} |
| 599 | if ours_commit_rec: |
| 600 | ours_snap_rec = read_snapshot(root, ours_commit_rec.snapshot_id) |
| 601 | if ours_snap_rec: |
| 602 | ours_ff_manifest = ours_snap_rec.manifest |
| 603 | added, modified, deleted = _diff_stats(ours_ff_manifest, ff_manifest) |
| 604 | |
| 605 | if not dry_run: |
| 606 | apply_manifest(root, ours_ff_manifest, ff_manifest) |
| 607 | try: |
| 608 | validate_branch_name(current_branch) |
| 609 | except ValueError as exc: |
| 610 | print( |
| 611 | f"❌ Current branch name is invalid: {sanitize_display(str(exc))}", |
| 612 | file=sys.stderr, |
| 613 | ) |
| 614 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 615 | try: |
| 616 | write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id) |
| 617 | except RefConflictError as exc: |
| 618 | print(f"❌ {exc}", file=sys.stderr) |
| 619 | raise SystemExit(ExitCode.USER_ERROR) |
| 620 | append_reflog( |
| 621 | root, current_branch, old_id=ours_commit_id, new_id=theirs_commit_id, |
| 622 | author="user", |
| 623 | operation=( |
| 624 | f"merge: fast-forward {sanitize_display(branch)} " |
| 625 | f"→ {sanitize_display(current_branch)}" |
| 626 | ), |
| 627 | ) |
| 628 | |
| 629 | if json_out: |
| 630 | _ff_commit_id = theirs_commit_id if not dry_run else None |
| 631 | _ff_files = {"added": added, "modified": modified, "deleted": deleted} |
| 632 | print(json.dumps(_MergeJsonBase( |
| 633 | **make_envelope(elapsed), |
| 634 | status="fast_forward", |
| 635 | commit_id=_ff_commit_id, |
| 636 | branch=branch, |
| 637 | current_branch=current_branch, |
| 638 | base_commit_id=base_commit_id, |
| 639 | conflicts=[], |
| 640 | files_changed=_ff_files, |
| 641 | semver_impact="", |
| 642 | strategy=reported_strategy, |
| 643 | on_conflict=on_conflict, |
| 644 | history=history, |
| 645 | dry_run=dry_run, |
| 646 | merge_result=_MergeResultDict( |
| 647 | status="fast_forward", |
| 648 | commit_id=_ff_commit_id, |
| 649 | strategy=reported_strategy, |
| 650 | on_conflict=on_conflict, |
| 651 | history=history, |
| 652 | conflicts=[], |
| 653 | files_changed=_ff_files, |
| 654 | semver_impact="", |
| 655 | ), |
| 656 | ))) |
| 657 | else: |
| 658 | if dry_run: |
| 659 | print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n") |
| 660 | print( |
| 661 | f"{_c('Updating', _DIM)} " |
| 662 | f"{_c(short_id(ours_commit_id), _YELLOW)}.." |
| 663 | f"{_c(short_id(theirs_commit_id), _YELLOW)}" |
| 664 | ) |
| 665 | label = "Would fast-forward" if dry_run else "Fast-forward" |
| 666 | print( |
| 667 | _c(label, _BOLD) |
| 668 | + f" {sanitize_display(branch)} → {sanitize_display(current_branch)}" |
| 669 | ) |
| 670 | _print_file_stats(added, modified, deleted) |
| 671 | return |
| 672 | |
| 673 | # Read both branch manifests — needed by Cases 3 and 4. |
| 674 | _ours_manifest = get_head_snapshot_manifest(root, current_branch) |
| 675 | if _ours_manifest is None: |
| 676 | print( |
| 677 | f"❌ Cannot read snapshot for branch '{sanitize_display(current_branch)}' " |
| 678 | "— aborting to prevent data loss.", |
| 679 | file=sys.stderr, |
| 680 | ) |
| 681 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 682 | ours_manifest: Manifest = _ours_manifest |
| 683 | |
| 684 | _theirs_manifest = get_head_snapshot_manifest(root, branch) |
| 685 | if _theirs_manifest is None: |
| 686 | print( |
| 687 | f"❌ Cannot read snapshot for branch '{sanitize_display(branch)}' " |
| 688 | "— aborting to prevent data loss.", |
| 689 | file=sys.stderr, |
| 690 | ) |
| 691 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 692 | theirs_manifest: Manifest = _theirs_manifest |
| 693 | |
| 694 | # Use STRATEGY_MAP as the single source of truth for routing. |
| 695 | # Look up the engine; default to recursive if no strategy specified. |
| 696 | _engine = STRATEGY_MAP.get(strategy or "recursive") |
| 697 | |
| 698 | # Determine effective resolution: --on-conflict overrides engine default. |
| 699 | if on_conflict == "ours": |
| 700 | _resolution = "prefer_ours" |
| 701 | elif on_conflict == "theirs": |
| 702 | _resolution = "prefer_theirs" |
| 703 | else: |
| 704 | _resolution = _engine.resolution if _engine else "escalate" |
| 705 | |
| 706 | # diff_unit determines execution path. snapshot/replay_* fall through to the |
| 707 | # Case 4 three-way path until Phase 6 adds separate execution paths. |
| 708 | _diff_unit = _engine.diff_unit if _engine else "three_way" |
| 709 | |
| 710 | # Route prefer_ours/prefer_theirs to Case 3 auto-resolve. |
| 711 | # For the case 3 path, map resolution back to a strategy string. |
| 712 | if _resolution == "prefer_ours": |
| 713 | strategy = "ours" |
| 714 | elif _resolution == "prefer_theirs": |
| 715 | strategy = "theirs" |
| 716 | else: |
| 717 | strategy = None # three-way escalate → Case 4 |
| 718 | |
| 719 | # ------------------------------------------------------------------- |
| 720 | # Case 3: auto-resolve — ours / theirs (driven by STRATEGY_MAP resolution) |
| 721 | # ------------------------------------------------------------------- |
| 722 | if strategy in ("ours", "theirs") and not dry_run: |
| 723 | base_manifest_strategy: Manifest = {} |
| 724 | if base_commit_id: |
| 725 | base_commit_rec = read_commit(root, base_commit_id) |
| 726 | if base_commit_rec is None: |
| 727 | print( |
| 728 | f"❌ Cannot read merge base commit {base_commit_id} " |
| 729 | "— aborting to prevent data loss.", |
| 730 | file=sys.stderr, |
| 731 | ) |
| 732 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 733 | base_snap_rec = read_snapshot(root, base_commit_rec.snapshot_id) |
| 734 | if base_snap_rec is None: |
| 735 | print( |
| 736 | f"❌ Cannot read snapshot for merge base {base_commit_id} " |
| 737 | "— aborting to prevent data loss.", |
| 738 | file=sys.stderr, |
| 739 | ) |
| 740 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 741 | base_manifest_strategy = base_snap_rec.manifest |
| 742 | |
| 743 | from muse.core.merge_engine import MergeEngine |
| 744 | _case3_engine = MergeEngine( |
| 745 | diff_unit=_diff_unit, |
| 746 | resolution="prefer_ours" if strategy == "ours" else "prefer_theirs", |
| 747 | ) |
| 748 | _case3_result = run_merge( |
| 749 | base_manifest_strategy, ours_manifest, theirs_manifest, |
| 750 | _case3_engine, plugin=plugin, repo_root=root, domain=domain, |
| 751 | ) |
| 752 | chosen_manifest = dict(_case3_result.merged["files"]) |
| 753 | |
| 754 | apply_manifest(root, {**ours_manifest, **theirs_manifest}, chosen_manifest) |
| 755 | |
| 756 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 757 | merge_author = sanitize_provenance(get_config_value("user.handle", root) or "") |
| 758 | safe_branch = sanitize_display(branch) |
| 759 | merge_msg = message or f"Merge branch '{safe_branch}' (--strategy={strategy})" |
| 760 | chosen_dirs = directories_from_manifest(chosen_manifest) |
| 761 | strategy_snap_id = hash_snapshot(chosen_manifest, chosen_dirs) |
| 762 | strategy_commit_id = hash_commit( |
| 763 | parent_ids=[ours_commit_id, theirs_commit_id], |
| 764 | snapshot_id=strategy_snap_id, |
| 765 | message=merge_msg, |
| 766 | committed_at_iso=committed_at.isoformat(), |
| 767 | author=merge_author, |
| 768 | ) |
| 769 | write_snapshot(root, SnapshotRecord(snapshot_id=strategy_snap_id, manifest=chosen_manifest, directories=chosen_dirs)) |
| 770 | write_commit(root, CommitRecord( |
| 771 | commit_id=strategy_commit_id, |
| 772 | branch=current_branch, |
| 773 | snapshot_id=strategy_snap_id, |
| 774 | message=merge_msg, |
| 775 | committed_at=committed_at, |
| 776 | parent_commit_id=ours_commit_id, |
| 777 | parent2_commit_id=theirs_commit_id, |
| 778 | author=merge_author, |
| 779 | )) |
| 780 | try: |
| 781 | validate_branch_name(current_branch) |
| 782 | except ValueError as exc: |
| 783 | print( |
| 784 | f"❌ Current branch name is invalid: {sanitize_display(str(exc))}", |
| 785 | file=sys.stderr, |
| 786 | ) |
| 787 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 788 | try: |
| 789 | write_branch_ref(root, current_branch, strategy_commit_id, expected_id=ours_commit_id) |
| 790 | except RefConflictError as exc: |
| 791 | print(f"❌ {exc}", file=sys.stderr) |
| 792 | raise SystemExit(ExitCode.USER_ERROR) |
| 793 | append_reflog( |
| 794 | root, current_branch, |
| 795 | old_id=ours_commit_id, new_id=strategy_commit_id, |
| 796 | author="user", |
| 797 | operation=( |
| 798 | f"merge (--strategy={strategy}): " |
| 799 | f"{sanitize_display(branch)} → {sanitize_display(current_branch)}" |
| 800 | ), |
| 801 | ) |
| 802 | strategy_added, strategy_modified, strategy_deleted = _diff_stats( |
| 803 | ours_manifest, chosen_manifest |
| 804 | ) |
| 805 | if json_out: |
| 806 | _s3_files = {"added": strategy_added, "modified": strategy_modified, "deleted": strategy_deleted} |
| 807 | print(json.dumps(_MergeJsonBase( |
| 808 | **make_envelope(elapsed), |
| 809 | status="merged", |
| 810 | commit_id=strategy_commit_id, |
| 811 | branch=branch, |
| 812 | current_branch=current_branch, |
| 813 | base_commit_id=base_commit_id, |
| 814 | conflicts=[], |
| 815 | files_changed=_s3_files, |
| 816 | semver_impact="", |
| 817 | strategy=reported_strategy, |
| 818 | on_conflict=on_conflict, |
| 819 | history=history, |
| 820 | dry_run=False, |
| 821 | merge_result=_MergeResultDict( |
| 822 | status="merged", |
| 823 | commit_id=strategy_commit_id, |
| 824 | strategy=reported_strategy, |
| 825 | on_conflict=on_conflict, |
| 826 | history=history, |
| 827 | conflicts=[], |
| 828 | files_changed=_s3_files, |
| 829 | semver_impact="", |
| 830 | ), |
| 831 | ))) |
| 832 | else: |
| 833 | print( |
| 834 | f"✅ Merged '{sanitize_display(branch)}' into " |
| 835 | f"'{sanitize_display(current_branch)}' " |
| 836 | f"using strategy '{strategy}' → {strategy_commit_id}" |
| 837 | ) |
| 838 | _print_file_stats(strategy_added, strategy_modified, strategy_deleted) |
| 839 | return |
| 840 | |
| 841 | # ------------------------------------------------------------------- |
| 842 | # Case 4: three-way merge |
| 843 | # ------------------------------------------------------------------- |
| 844 | base_manifest: Manifest = {} |
| 845 | if base_commit_id: |
| 846 | base_commit = read_commit(root, base_commit_id) |
| 847 | if base_commit is None: |
| 848 | print( |
| 849 | f"❌ Cannot read merge base commit {base_commit_id} " |
| 850 | "— aborting to prevent data loss.", |
| 851 | file=sys.stderr, |
| 852 | ) |
| 853 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 854 | base_snap = read_snapshot(root, base_commit.snapshot_id) |
| 855 | if base_snap is None: |
| 856 | print( |
| 857 | f"❌ Cannot read snapshot for merge base {base_commit_id} " |
| 858 | "— aborting to prevent data loss.", |
| 859 | file=sys.stderr, |
| 860 | ) |
| 861 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 862 | base_manifest = base_snap.manifest |
| 863 | |
| 864 | merge_debug_log("merge.case4.enter", { |
| 865 | "caller": "muse_merge", |
| 866 | "current_branch": current_branch, |
| 867 | "source_branch": branch, |
| 868 | "base_commit_id": base_commit_id, |
| 869 | "ours_commit_id": ours_commit_id, |
| 870 | "theirs_commit_id": theirs_commit_id, |
| 871 | "base_manifest": merge_debug_manifest_summary(base_manifest), |
| 872 | "ours_manifest": merge_debug_manifest_summary(ours_manifest), |
| 873 | "theirs_manifest": merge_debug_manifest_summary(theirs_manifest), |
| 874 | "strategy": strategy, |
| 875 | }) |
| 876 | |
| 877 | from muse.core.merge_engine import MergeEngine |
| 878 | _case4_engine = MergeEngine(diff_unit=_diff_unit, resolution=_resolution) |
| 879 | result = run_merge( |
| 880 | base_manifest, ours_manifest, theirs_manifest, |
| 881 | _case4_engine, plugin=plugin, repo_root=root, domain=domain, |
| 882 | ) |
| 883 | structured = isinstance(plugin, AddressedMergePlugin) |
| 884 | |
| 885 | # Capture pre-harmony conflicts and prepare harmony decisions dict for explain. |
| 886 | pre_harmony_conflicts: list[str] = list(result.conflicts) |
| 887 | harmony_decisions: dict = {} |
| 888 | |
| 889 | merge_debug_log("merge.case4.result", { |
| 890 | "caller": "muse_merge", |
| 891 | "is_clean": result.is_clean, |
| 892 | "conflicts": result.conflicts, |
| 893 | "applied_strategies": result.applied_strategies, |
| 894 | "merged_file_count": len(result.merged["files"]), |
| 895 | }) |
| 896 | |
| 897 | if result.applied_strategies and not json_out: |
| 898 | for p, strat in sorted(result.applied_strategies.items()): |
| 899 | safe_p = sanitize_display(p) |
| 900 | safe_strat = sanitize_display(strat) |
| 901 | if strat == "dimension-merge": |
| 902 | dim_detail = result.dimension_reports.get(p, {}) |
| 903 | dim_summary = ", ".join( |
| 904 | f"{sanitize_display(d)}={sanitize_display(str(v))}" |
| 905 | for d, v in sorted(dim_detail.items()) |
| 906 | ) |
| 907 | print(f" ✔ dimension-merge: {safe_p} ({dim_summary})") |
| 908 | elif strat != "manual": |
| 909 | print(f" ✔ [{safe_strat}] {safe_p}") |
| 910 | |
| 911 | if not result.is_clean: |
| 912 | harmony_resolved: Manifest = {} |
| 913 | remaining_conflicts = result.conflicts |
| 914 | |
| 915 | if harmony_autoupdate and not dry_run: |
| 916 | harmony_resolved, remaining_conflicts = harmony_auto_apply( |
| 917 | root, |
| 918 | result.conflicts, |
| 919 | ours_manifest, |
| 920 | theirs_manifest, |
| 921 | domain, |
| 922 | plugin, |
| 923 | explain_decisions=harmony_decisions if explain else None, |
| 924 | ) |
| 925 | if not json_out: |
| 926 | for p in sorted(harmony_resolved): |
| 927 | print(f" ✔ [harmony] auto-resolved: {sanitize_display(p)}") |
| 928 | |
| 929 | # prefer_ours/prefer_theirs auto-resolves all conflicts — skip conflict reporting. |
| 930 | if remaining_conflicts and _resolution in ("prefer_ours", "prefer_theirs"): |
| 931 | remaining_conflicts = [] |
| 932 | |
| 933 | if remaining_conflicts: |
| 934 | if not dry_run: |
| 935 | # apply_manifest BEFORE write_merge_state — safe crash ordering. |
| 936 | # A crash after apply but before write leaves a dirty tree with no |
| 937 | # MERGE_STATE.json (recoverable: re-run merge). A crash after |
| 938 | # write_merge_state but before apply leaves MERGE_STATE claiming a |
| 939 | # conflict while the workdir is still clean — the user cannot resolve. |
| 940 | conflict_file_paths: set[str] = { |
| 941 | p.split("::")[0] for p in remaining_conflicts |
| 942 | } |
| 943 | partial_merged: Manifest = dict(ours_manifest) |
| 944 | for path, oid in result.merged["files"].items(): |
| 945 | if path not in conflict_file_paths: |
| 946 | partial_merged[path] = oid |
| 947 | apply_manifest(root, ours_manifest, partial_merged) |
| 948 | |
| 949 | # Write Cohen Transform conflict markers into each conflicting |
| 950 | # file so the user can see both sides and resolve manually. |
| 951 | # Binary files are left at the ours version (same policy as checkout). |
| 952 | from muse.core.cohen_transform import three_way_merge_lines |
| 953 | from muse.core.object_store import read_object |
| 954 | from muse.core.io import write_text_atomic |
| 955 | |
| 956 | def _is_binary(b: bytes) -> bool: |
| 957 | return b"\0" in b |
| 958 | |
| 959 | for cpath in conflict_file_paths: |
| 960 | ours_oid = ours_manifest.get(cpath) |
| 961 | theirs_oid = theirs_manifest.get(cpath) |
| 962 | base_oid = base_manifest.get(cpath) |
| 963 | if not ours_oid or not theirs_oid: |
| 964 | continue |
| 965 | ours_bytes = read_object(root, ours_oid) or b"" |
| 966 | theirs_bytes = read_object(root, theirs_oid) or b"" |
| 967 | base_bytes = read_object(root, base_oid) if base_oid else b"" |
| 968 | if _is_binary(ours_bytes) or _is_binary(theirs_bytes) or _is_binary(base_bytes): |
| 969 | continue |
| 970 | base_lines = base_bytes.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 971 | ours_lines = ours_bytes.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 972 | theirs_lines = theirs_bytes.decode("utf-8", errors="replace").splitlines(keepends=True) |
| 973 | merged_lines, _ = three_way_merge_lines( |
| 974 | base_lines, ours_lines, theirs_lines, |
| 975 | label_ours=current_branch, |
| 976 | label_base="base", |
| 977 | label_theirs=branch, |
| 978 | ) |
| 979 | dest = root / cpath |
| 980 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 981 | write_text_atomic(dest, "".join(merged_lines)) |
| 982 | |
| 983 | write_merge_state( |
| 984 | root, |
| 985 | base_commit=base_commit_id or "", |
| 986 | ours_commit=ours_commit_id, |
| 987 | theirs_commit=theirs_commit_id, |
| 988 | conflict_paths=remaining_conflicts, |
| 989 | other_branch=branch, |
| 990 | ) |
| 991 | |
| 992 | symbol_conflicts: list[_SymbolConflictDict] = [] |
| 993 | conflict_records_out: list[ConflictDict] = [] |
| 994 | if result.conflict_records: |
| 995 | for rec in result.conflict_records: |
| 996 | conflict_records_out.append(rec.to_dict()) |
| 997 | if structured and rec.addresses: |
| 998 | symbol_conflicts.append({ |
| 999 | "path": sanitize_display(rec.path), |
| 1000 | "symbol": sanitize_display(",".join(rec.addresses)), |
| 1001 | "ours": sanitize_display(rec.ours_summary), |
| 1002 | "theirs": sanitize_display(rec.theirs_summary), |
| 1003 | }) |
| 1004 | |
| 1005 | if json_out: |
| 1006 | _conflict_list = sorted(remaining_conflicts) |
| 1007 | out = _MergeJson( |
| 1008 | **make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)), |
| 1009 | status="conflict", |
| 1010 | commit_id=None, |
| 1011 | branch=branch, |
| 1012 | current_branch=current_branch, |
| 1013 | base_commit_id=base_commit_id, |
| 1014 | conflicts=_conflict_list, |
| 1015 | files_changed={"added": 0, "modified": 0, "deleted": 0}, |
| 1016 | semver_impact="", |
| 1017 | strategy=strategy, |
| 1018 | on_conflict=on_conflict, |
| 1019 | history=history, |
| 1020 | dry_run=dry_run, |
| 1021 | merge_result=_MergeResultDict( |
| 1022 | status="conflict", |
| 1023 | commit_id=None, |
| 1024 | strategy=strategy, |
| 1025 | on_conflict=on_conflict, |
| 1026 | history=history, |
| 1027 | conflicts=_conflict_list, |
| 1028 | files_changed={"added": 0, "modified": 0, "deleted": 0}, |
| 1029 | semver_impact="", |
| 1030 | ), |
| 1031 | ) |
| 1032 | out["conflict_records"] = conflict_records_out |
| 1033 | out["symbol_conflicts"] = symbol_conflicts |
| 1034 | if explain: |
| 1035 | out["explain"] = _build_explain_trace( |
| 1036 | requested_strategy=reported_strategy, |
| 1037 | on_conflict=on_conflict, |
| 1038 | history=history, |
| 1039 | diff_unit=_diff_unit, |
| 1040 | resolution=_resolution, |
| 1041 | base_commit_id=base_commit_id, |
| 1042 | base_manifest=base_manifest, |
| 1043 | ours_manifest=ours_manifest, |
| 1044 | theirs_manifest=theirs_manifest, |
| 1045 | result_manifest=result.merged["files"], |
| 1046 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1047 | harmony_decisions=harmony_decisions, |
| 1048 | harmony_was_run=(harmony_autoupdate and not dry_run), |
| 1049 | ) |
| 1050 | print(json.dumps(out)) |
| 1051 | else: |
| 1052 | if dry_run: |
| 1053 | print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n") |
| 1054 | print( |
| 1055 | f"❌ {'Would have merge' if dry_run else 'Merge'} " |
| 1056 | f"conflict in {len(remaining_conflicts)} file(s):", |
| 1057 | file=sys.stderr, |
| 1058 | ) |
| 1059 | for p in sorted(remaining_conflicts): |
| 1060 | print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr) |
| 1061 | if symbol_conflicts: |
| 1062 | print( |
| 1063 | f"\n Symbol-level conflicts ({len(symbol_conflicts)}):", |
| 1064 | file=sys.stderr, |
| 1065 | ) |
| 1066 | for sc in symbol_conflicts[:10]: |
| 1067 | print(f" {sc['path']}::{sc['symbol']}", file=sys.stderr) |
| 1068 | if len(symbol_conflicts) > 10: |
| 1069 | print( |
| 1070 | f" … and {len(symbol_conflicts) - 10} more", |
| 1071 | file=sys.stderr, |
| 1072 | ) |
| 1073 | if not dry_run: |
| 1074 | print( |
| 1075 | '\nFix conflicts and run "muse commit" to complete the merge.', |
| 1076 | file=sys.stderr, |
| 1077 | ) |
| 1078 | if explain: |
| 1079 | _print_explain_trace(_build_explain_trace( |
| 1080 | requested_strategy=reported_strategy, |
| 1081 | on_conflict=on_conflict, |
| 1082 | history=history, |
| 1083 | diff_unit=_diff_unit, |
| 1084 | resolution=_resolution, |
| 1085 | base_commit_id=base_commit_id, |
| 1086 | base_manifest=base_manifest, |
| 1087 | ours_manifest=ours_manifest, |
| 1088 | theirs_manifest=theirs_manifest, |
| 1089 | result_manifest=result.merged["files"], |
| 1090 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1091 | harmony_decisions=harmony_decisions, |
| 1092 | harmony_was_run=(harmony_autoupdate and not dry_run), |
| 1093 | )) |
| 1094 | raise SystemExit(ExitCode.USER_ERROR) |
| 1095 | |
| 1096 | if not dry_run: |
| 1097 | merged_files = dict(result.merged["files"]) |
| 1098 | merged_files.update(harmony_resolved) |
| 1099 | result = MergeResult( |
| 1100 | merged=SnapshotManifest(files=merged_files, domain=domain, directories=directories_from_manifest(merged_files)), |
| 1101 | conflicts=[], |
| 1102 | applied_strategies=result.applied_strategies, |
| 1103 | dimension_reports=result.dimension_reports, |
| 1104 | op_log=result.op_log, |
| 1105 | conflict_records=result.conflict_records, |
| 1106 | ) |
| 1107 | |
| 1108 | merged_manifest = result.merged["files"] |
| 1109 | added, modified, deleted = _diff_stats(ours_manifest, merged_manifest) |
| 1110 | |
| 1111 | if dry_run: |
| 1112 | semver_impact = _semver_from_op_log(result.op_log if structured else []) |
| 1113 | if json_out: |
| 1114 | _dr_files = {"added": added, "modified": modified, "deleted": deleted} |
| 1115 | _dr_envelope = _MergeJsonBase( |
| 1116 | **make_envelope(elapsed), |
| 1117 | status="merged", |
| 1118 | commit_id=None, |
| 1119 | branch=branch, |
| 1120 | current_branch=current_branch, |
| 1121 | base_commit_id=base_commit_id, |
| 1122 | conflicts=[], |
| 1123 | files_changed=_dr_files, |
| 1124 | semver_impact=semver_impact, |
| 1125 | strategy=reported_strategy, |
| 1126 | on_conflict=on_conflict, |
| 1127 | history=history, |
| 1128 | dry_run=True, |
| 1129 | merge_result=_MergeResultDict( |
| 1130 | status="merged", |
| 1131 | commit_id=None, |
| 1132 | strategy=reported_strategy, |
| 1133 | on_conflict=on_conflict, |
| 1134 | history=history, |
| 1135 | conflicts=[], |
| 1136 | files_changed=_dr_files, |
| 1137 | semver_impact=semver_impact, |
| 1138 | ), |
| 1139 | ) |
| 1140 | if explain: |
| 1141 | _dr_envelope["explain"] = _build_explain_trace( |
| 1142 | requested_strategy=reported_strategy, |
| 1143 | on_conflict=on_conflict, |
| 1144 | history=history, |
| 1145 | diff_unit=_diff_unit, |
| 1146 | resolution=_resolution, |
| 1147 | base_commit_id=base_commit_id, |
| 1148 | base_manifest=base_manifest, |
| 1149 | ours_manifest=ours_manifest, |
| 1150 | theirs_manifest=theirs_manifest, |
| 1151 | result_manifest=result.merged["files"], |
| 1152 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1153 | harmony_decisions=harmony_decisions, |
| 1154 | harmony_was_run=False, |
| 1155 | ) |
| 1156 | print(json.dumps(_dr_envelope)) |
| 1157 | else: |
| 1158 | print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n") |
| 1159 | print(f"{_c('Would merge', _BOLD)} by the three-way strategy.") |
| 1160 | print(f" {sanitize_display(branch)} → {sanitize_display(current_branch)}") |
| 1161 | _print_file_stats(added, modified, deleted) |
| 1162 | if semver_impact: |
| 1163 | print(f" Proposed semver bump: {_c(semver_impact, _YELLOW)}") |
| 1164 | if explain: |
| 1165 | _print_explain_trace(_build_explain_trace( |
| 1166 | requested_strategy=reported_strategy, |
| 1167 | on_conflict=on_conflict, |
| 1168 | history=history, |
| 1169 | diff_unit=_diff_unit, |
| 1170 | resolution=_resolution, |
| 1171 | base_commit_id=base_commit_id, |
| 1172 | base_manifest=base_manifest, |
| 1173 | ours_manifest=ours_manifest, |
| 1174 | theirs_manifest=theirs_manifest, |
| 1175 | result_manifest=result.merged["files"], |
| 1176 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1177 | harmony_decisions=harmony_decisions, |
| 1178 | harmony_was_run=False, |
| 1179 | )) |
| 1180 | return |
| 1181 | |
| 1182 | if not merged_manifest and (ours_manifest or theirs_manifest): |
| 1183 | print( |
| 1184 | "❌ Internal error: merge produced an empty manifest despite non-empty " |
| 1185 | "inputs — aborting to prevent data loss.", |
| 1186 | file=sys.stderr, |
| 1187 | ) |
| 1188 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 1189 | |
| 1190 | apply_manifest(root, {**ours_manifest, **theirs_manifest}, merged_manifest) |
| 1191 | |
| 1192 | merged_dirs = directories_from_manifest(merged_manifest) |
| 1193 | snapshot_id = hash_snapshot(merged_manifest, merged_dirs) |
| 1194 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 1195 | merge_author = sanitize_provenance(get_config_value("user.handle", root) or "") |
| 1196 | safe_branch = sanitize_display(branch) |
| 1197 | safe_current = sanitize_display(current_branch) |
| 1198 | |
| 1199 | # --history squash/rebase: single-parent commit, no merge parent |
| 1200 | # (rebase full commit-by-commit replay is Phase 4 scope) |
| 1201 | squash = history in ("squash", "rebase") |
| 1202 | if squash: |
| 1203 | if history == "rebase": |
| 1204 | merge_message = message or f"Rebase branch '{safe_branch}' onto {safe_current}" |
| 1205 | else: |
| 1206 | merge_message = message or f"Squash merge branch '{safe_branch}' into {safe_current}" |
| 1207 | commit_id = hash_commit( |
| 1208 | parent_ids=[ours_commit_id], |
| 1209 | snapshot_id=snapshot_id, |
| 1210 | message=merge_message, |
| 1211 | committed_at_iso=committed_at.isoformat(), |
| 1212 | author=merge_author, |
| 1213 | ) |
| 1214 | write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs)) |
| 1215 | write_commit(root, CommitRecord( |
| 1216 | commit_id=commit_id, |
| 1217 | branch=current_branch, |
| 1218 | snapshot_id=snapshot_id, |
| 1219 | message=merge_message, |
| 1220 | committed_at=committed_at, |
| 1221 | parent_commit_id=ours_commit_id, |
| 1222 | parent2_commit_id=None, |
| 1223 | author=merge_author, |
| 1224 | )) |
| 1225 | else: |
| 1226 | merge_message = message or f"Merge branch '{safe_branch}' into {safe_current}" |
| 1227 | commit_id = hash_commit( |
| 1228 | parent_ids=[ours_commit_id, theirs_commit_id], |
| 1229 | snapshot_id=snapshot_id, |
| 1230 | message=merge_message, |
| 1231 | committed_at_iso=committed_at.isoformat(), |
| 1232 | author=merge_author, |
| 1233 | ) |
| 1234 | write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs)) |
| 1235 | write_commit(root, CommitRecord( |
| 1236 | commit_id=commit_id, |
| 1237 | branch=current_branch, |
| 1238 | snapshot_id=snapshot_id, |
| 1239 | message=merge_message, |
| 1240 | committed_at=committed_at, |
| 1241 | parent_commit_id=ours_commit_id, |
| 1242 | parent2_commit_id=theirs_commit_id, |
| 1243 | author=merge_author, |
| 1244 | )) |
| 1245 | try: |
| 1246 | validate_branch_name(current_branch) |
| 1247 | except ValueError as exc: |
| 1248 | print( |
| 1249 | f"❌ Current branch name is invalid: {sanitize_display(str(exc))}", |
| 1250 | file=sys.stderr, |
| 1251 | ) |
| 1252 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 1253 | try: |
| 1254 | write_branch_ref(root, current_branch, commit_id, expected_id=ours_commit_id) |
| 1255 | except RefConflictError as exc: |
| 1256 | print(f"❌ {exc}", file=sys.stderr) |
| 1257 | raise SystemExit(ExitCode.USER_ERROR) |
| 1258 | |
| 1259 | append_reflog( |
| 1260 | root, current_branch, old_id=ours_commit_id, new_id=commit_id, |
| 1261 | author="user", |
| 1262 | operation=f"merge: {sanitize_display(branch)} into {sanitize_display(current_branch)}", |
| 1263 | ) |
| 1264 | |
| 1265 | if json_out: |
| 1266 | _live_files = {"added": added, "modified": modified, "deleted": deleted} |
| 1267 | _live_semver = _semver_from_op_log(result.op_log if structured else []) |
| 1268 | _live_envelope = _MergeJsonBase( |
| 1269 | **make_envelope(elapsed), |
| 1270 | status="merged", |
| 1271 | commit_id=commit_id, |
| 1272 | branch=branch, |
| 1273 | current_branch=current_branch, |
| 1274 | base_commit_id=base_commit_id, |
| 1275 | conflicts=[], |
| 1276 | files_changed=_live_files, |
| 1277 | semver_impact=_live_semver, |
| 1278 | strategy=strategy, |
| 1279 | on_conflict=on_conflict, |
| 1280 | history=history, |
| 1281 | dry_run=False, |
| 1282 | merge_result=_MergeResultDict( |
| 1283 | status="merged", |
| 1284 | commit_id=commit_id, |
| 1285 | strategy=strategy, |
| 1286 | on_conflict=on_conflict, |
| 1287 | history=history, |
| 1288 | conflicts=[], |
| 1289 | files_changed=_live_files, |
| 1290 | semver_impact=_live_semver, |
| 1291 | ), |
| 1292 | ) |
| 1293 | if explain: |
| 1294 | _live_envelope["explain"] = _build_explain_trace( |
| 1295 | requested_strategy=reported_strategy, |
| 1296 | on_conflict=on_conflict, |
| 1297 | history=history, |
| 1298 | diff_unit=_diff_unit, |
| 1299 | resolution=_resolution, |
| 1300 | base_commit_id=base_commit_id, |
| 1301 | base_manifest=base_manifest, |
| 1302 | ours_manifest=ours_manifest, |
| 1303 | theirs_manifest=theirs_manifest, |
| 1304 | result_manifest=merged_manifest, |
| 1305 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1306 | harmony_decisions=harmony_decisions, |
| 1307 | harmony_was_run=(harmony_autoupdate and not dry_run), |
| 1308 | ) |
| 1309 | print(json.dumps(_live_envelope)) |
| 1310 | else: |
| 1311 | print(f"{_c('Merge', _BOLD)} made by the three-way strategy.") |
| 1312 | print( |
| 1313 | f" {sanitize_display(branch)} → {sanitize_display(current_branch)}" |
| 1314 | f" {_c(short_id(commit_id), _YELLOW)}" |
| 1315 | ) |
| 1316 | _print_file_stats(added, modified, deleted) |
| 1317 | if explain: |
| 1318 | _print_explain_trace(_build_explain_trace( |
| 1319 | requested_strategy=reported_strategy, |
| 1320 | on_conflict=on_conflict, |
| 1321 | history=history, |
| 1322 | diff_unit=_diff_unit, |
| 1323 | resolution=_resolution, |
| 1324 | base_commit_id=base_commit_id, |
| 1325 | base_manifest=base_manifest, |
| 1326 | ours_manifest=ours_manifest, |
| 1327 | theirs_manifest=theirs_manifest, |
| 1328 | result_manifest=merged_manifest, |
| 1329 | pre_harmony_conflicts=pre_harmony_conflicts, |
| 1330 | harmony_decisions=harmony_decisions, |
| 1331 | harmony_was_run=(harmony_autoupdate and not dry_run), |
| 1332 | )) |
| 1333 | |
| 1334 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 1335 | """Register the ``muse merge`` subcommand and all its flags.""" |
| 1336 | parser = subparsers.add_parser( |
| 1337 | "merge", |
| 1338 | help="Three-way merge a branch into the current branch.", |
| 1339 | description=__doc__, |
| 1340 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1341 | ) |
| 1342 | parser.add_argument( |
| 1343 | "branch", nargs="?", default=None, |
| 1344 | help="Branch to merge into the current branch.", |
| 1345 | ) |
| 1346 | parser.add_argument( |
| 1347 | "--no-ff", action="store_true", |
| 1348 | help="Always create a merge commit, even for fast-forward.", |
| 1349 | ) |
| 1350 | parser.add_argument( |
| 1351 | "-m", "--message", default=None, |
| 1352 | help="Override the merge commit message.", |
| 1353 | ) |
| 1354 | parser.add_argument( |
| 1355 | "--harmony-autoupdate", action="store_true", default=True, |
| 1356 | dest="harmony_autoupdate", |
| 1357 | help="Automatically apply cached harmony resolutions (default: on).", |
| 1358 | ) |
| 1359 | parser.add_argument( |
| 1360 | "--no-harmony-autoupdate", action="store_false", |
| 1361 | dest="harmony_autoupdate", |
| 1362 | help="Disable harmony auto-update.", |
| 1363 | ) |
| 1364 | parser.add_argument( |
| 1365 | "--force", "-f", action="store_true", |
| 1366 | help="Proceed even with uncommitted changes (data-loss risk).", |
| 1367 | ) |
| 1368 | parser.add_argument( |
| 1369 | "--dry-run", "-n", action="store_true", dest="dry_run", |
| 1370 | help=( |
| 1371 | "Simulate the merge without writing anything. " |
| 1372 | "Reports fast-forward, clean merge, or conflicts — including " |
| 1373 | "symbol-level conflict detail for structured-merge plugins." |
| 1374 | ), |
| 1375 | ) |
| 1376 | parser.add_argument( |
| 1377 | "--strategy", "-s", |
| 1378 | choices=["recursive", "overlay", "snapshot", "replay", "ours", "theirs"], |
| 1379 | default=None, |
| 1380 | dest="strategy", |
| 1381 | help=( |
| 1382 | "Merge strategy (diff_unit). " |
| 1383 | "'recursive' — three-way delta merge (default); " |
| 1384 | "'overlay' — apply theirs on top of ours (snapshot, no base); " |
| 1385 | "'snapshot' — snapshot-level comparison, surface conflicts; " |
| 1386 | "'replay' — apply ours' delta onto theirs' state; " |
| 1387 | "'ours' / 'theirs' — convenience aliases for recursive + --on-conflict." |
| 1388 | ), |
| 1389 | ) |
| 1390 | parser.add_argument( |
| 1391 | "--on-conflict", |
| 1392 | choices=["escalate", "ours", "theirs"], |
| 1393 | default=None, |
| 1394 | dest="on_conflict", |
| 1395 | help=( |
| 1396 | "Resolution policy when a conflict is detected. " |
| 1397 | "'escalate' — surface it for human/Harmony resolution (default); " |
| 1398 | "'ours' — auto-resolve all conflicts keeping our version; " |
| 1399 | "'theirs' — auto-resolve all conflicts keeping their version." |
| 1400 | ), |
| 1401 | ) |
| 1402 | parser.add_argument( |
| 1403 | "--history", |
| 1404 | choices=["merge", "squash", "rebase"], |
| 1405 | default=None, |
| 1406 | dest="history", |
| 1407 | help=( |
| 1408 | "History mode. " |
| 1409 | "'merge' — two-parent merge commit (default); " |
| 1410 | "'squash' — flatten incoming branch into one commit, no merge parent; " |
| 1411 | "'rebase' — replay incoming commits linearly on top of ours." |
| 1412 | ), |
| 1413 | ) |
| 1414 | parser.add_argument( |
| 1415 | "--abort", action="store_true", |
| 1416 | help="Abort an in-progress merge and restore the working tree.", |
| 1417 | ) |
| 1418 | parser.add_argument( |
| 1419 | "--autoshelf", |
| 1420 | dest="autoshelf", |
| 1421 | action="store_true", |
| 1422 | help=( |
| 1423 | "Automatically shelf uncommitted changes before merging and pop " |
| 1424 | "them back onto the working tree after the merge completes. " |
| 1425 | "Equivalent to running ``muse shelf save`` before merge and " |
| 1426 | "``muse shelf pop`` after. " |
| 1427 | "Mutually exclusive with --force." |
| 1428 | ), |
| 1429 | ) |
| 1430 | parser.add_argument( |
| 1431 | "--json", "-j", action="store_true", dest="json_out", |
| 1432 | help="Emit machine-readable JSON.", |
| 1433 | ) |
| 1434 | parser.add_argument( |
| 1435 | "--explain", action="store_true", default=False, dest="explain", |
| 1436 | help=( |
| 1437 | "Print a per-path decision trace alongside the merge output. " |
| 1438 | "With --json, embeds an 'explain' key in the JSON envelope. " |
| 1439 | "With --dry-run, shows what decisions would be made without writing anything. " |
| 1440 | "Does not change merge behavior — only adds observation." |
| 1441 | ), |
| 1442 | ) |
| 1443 | parser.set_defaults(func=run, autoshelf=False) |
| 1444 | |
| 1445 | def run(args: argparse.Namespace) -> None: |
| 1446 | """Three-way merge a branch into the current branch. |
| 1447 | |
| 1448 | Performs a three-way merge (or fast-forward when possible) and emits a |
| 1449 | structured result. Pass ``--dry-run`` to simulate without writing. |
| 1450 | Pass ``--abort`` to cancel an in-progress merge and restore the working |
| 1451 | tree to the pre-merge state. |
| 1452 | |
| 1453 | Agent quickstart |
| 1454 | ---------------- |
| 1455 | :: |
| 1456 | |
| 1457 | muse merge feat/billing --format json |
| 1458 | muse merge feat/billing --dry-run --format json |
| 1459 | muse merge feat/billing --strategy ours --format json |
| 1460 | muse merge --abort --format json |
| 1461 | |
| 1462 | JSON fields |
| 1463 | ----------- |
| 1464 | status ``"merged"``, ``"fast_forward"``, ``"conflict"``, or ``"up_to_date"``. |
| 1465 | commit_id New merge commit ID, or ``null`` on conflict or dry-run. |
| 1466 | branch Source branch being merged in. |
| 1467 | current_branch Target branch being merged into. |
| 1468 | base_commit_id Common ancestor commit ID, or ``null``. |
| 1469 | conflicts List of paths with unresolved conflicts. |
| 1470 | files_changed ``{"added": N, "modified": N, "deleted": N}`` file counts. |
| 1471 | semver_impact Proposed semver bump (``"MAJOR"``, ``"MINOR"``, ``"PATCH"``, or ``""``). |
| 1472 | strategy Merge strategy used, or ``null``. |
| 1473 | dry_run ``true`` when ``--dry-run`` was passed. |
| 1474 | |
| 1475 | Exit codes |
| 1476 | ---------- |
| 1477 | 0 Success — merged, fast-forwarded, or already up to date. |
| 1478 | 1 Conflict detected, or invalid arguments. |
| 1479 | 3 Internal error — missing snapshot or commit. |
| 1480 | """ |
| 1481 | elapsed = start_timer() |
| 1482 | abort: bool = getattr(args, "abort", False) |
| 1483 | json_out: bool = args.json_out |
| 1484 | |
| 1485 | if abort: |
| 1486 | _run_abort(json_out) |
| 1487 | return |
| 1488 | |
| 1489 | branch: str | None = args.branch |
| 1490 | if branch is None: |
| 1491 | _emit_error( |
| 1492 | json_out, |
| 1493 | "Usage: muse merge <branch> [options]. To cancel an in-progress merge: muse merge --abort", |
| 1494 | ExitCode.USER_ERROR, |
| 1495 | elapsed, |
| 1496 | ) |
| 1497 | |
| 1498 | no_ff: bool = args.no_ff |
| 1499 | message: str | None = args.message |
| 1500 | harmony_autoupdate: bool = args.harmony_autoupdate |
| 1501 | force: bool = args.force |
| 1502 | dry_run: bool = getattr(args, "dry_run", False) |
| 1503 | strategy: str | None = getattr(args, "strategy", None) |
| 1504 | on_conflict: str | None = getattr(args, "on_conflict", None) |
| 1505 | history: str | None = getattr(args, "history", None) |
| 1506 | autoshelf: bool = getattr(args, "autoshelf", False) |
| 1507 | explain: bool = getattr(args, "explain", False) |
| 1508 | |
| 1509 | if autoshelf and force: |
| 1510 | _emit_error(json_out, "--autoshelf and --force are mutually exclusive.", ExitCode.USER_ERROR, elapsed) |
| 1511 | |
| 1512 | root = require_repo() |
| 1513 | |
| 1514 | # Autoshelf: save working-tree changes before the merge so require_clean_workdir |
| 1515 | # passes, then pop them back in the finally block regardless of outcome. |
| 1516 | autoshelf_entry = None |
| 1517 | if autoshelf and not dry_run: |
| 1518 | current_branch_for_merge = read_current_branch(root) |
| 1519 | autoshelf_entry = _shelf_push_programmatic( |
| 1520 | root, |
| 1521 | name=f"_auto/{sanitize_display(current_branch_for_merge)}", |
| 1522 | intent_type="interrupt", |
| 1523 | intent=f"autoshelf before merge of {sanitize_display(branch)}", |
| 1524 | created_by="muse", |
| 1525 | ) |
| 1526 | if autoshelf_entry is not None and not json_out: |
| 1527 | print("autoshelf: saved working directory state", file=sys.stderr) |
| 1528 | |
| 1529 | try: |
| 1530 | _run_merge( |
| 1531 | root=root, |
| 1532 | branch=branch, |
| 1533 | no_ff=no_ff, |
| 1534 | message=message, |
| 1535 | harmony_autoupdate=harmony_autoupdate, |
| 1536 | force=force, |
| 1537 | dry_run=dry_run, |
| 1538 | strategy=strategy, |
| 1539 | json_out=json_out, |
| 1540 | on_conflict=on_conflict, |
| 1541 | history=history, |
| 1542 | explain=explain, |
| 1543 | ) |
| 1544 | finally: |
| 1545 | if autoshelf_entry is not None: |
| 1546 | _apply_autoshelf(root, json_out) |
File History
8 commits
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47
feat(merge): add --explain flag with per-path decision trac…
Sonnet 4.6
minor
⚠
21 hours ago
sha256:ecfc7b5d19db951f256942ac0908b53d55a2da37c6cd1e6cf85b4a6088870865
feat(phase6): unified MergeEngine code path via run_merge()
Sonnet 4.6
patch
22 hours ago
sha256:f02589f8e157757da430d82f35a64c0b7eee5033f6d13076ea395f9942151790
test(phase3): full strategy matrix — 24 SM tests, rebase→linear
Sonnet 4.6
23 hours ago
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb
feat: add merge_result sub-object to muse merge --json (del…
Sonnet 4.6
1 day ago
sha256:0ab1022a97637701da7c18808e65556a5c774a1572b42e02599ff55efaf69ef4
feat: route merge.py through STRATEGY_MAP; update hub propo…
Sonnet 4.6
patch
1 day ago
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1
feat: Phase 1 — MergeEngine class, --on-conflict, --history…
Sonnet 4.6
patch
1 day ago
sha256:8c92016d30056bba10f40c739abdcef82334fd27185fe6d7f17bef3418f56131
test: PHANTOM_01-05 regression tests + overlay/state_merge …
Sonnet 4.6
patch
1 day ago
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f
fix: convergent-edit phantom conflicts in ops_commute + mer…
Sonnet 4.6
minor
⚠
1 day ago