gabriel / muse public
merge.py python
1,546 lines 61.3 KB
Raw
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