gabriel / muse public
merge.py python
1,544 lines 61.2 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 3 days 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 print(f"\n Merge base: {base_cid}")
447 print(f" Strategy: {strategy_name} ({diff_unit} + {resolution}) → Case {sr.get('case', '?')}")
448 print()
449
450 _DECISION_LABEL = {
451 "no_change": "no-change",
452 "take_ours_only": "take-ours",
453 "take_theirs_only": "take-theirs",
454 "convergent": "convergent",
455 "harmony_auto_resolved": "harmony",
456 "attributes_auto_resolved": "attributes",
457 "independence_merged": "independence",
458 "conflict": "CONFLICT",
459 "overlay_auto_resolved": "overlay",
460 }
461
462 for entry in trace["per_path"]:
463 path = sanitize_display(entry["path"])
464 label = _DECISION_LABEL.get(entry["decision"], entry["decision"])
465 reason = entry.get("reason", "")
466 pattern_id = entry.get("harmony_pattern_id")
467 extra = ""
468 if pattern_id:
469 extra = f" (pattern {pattern_id})"
470 print(f" {path:<30} {label:<14} {reason}{extra}")
471
472 s = trace["summary"]
473 parts = []
474 if s.get("conflicts"):
475 parts.append(f"{s['conflicts']} conflict{'s' if s['conflicts'] != 1 else ''}")
476 if s.get("harmony_auto_resolved"):
477 parts.append(f"{s['harmony_auto_resolved']} harmony auto-resolved")
478 if s.get("convergent"):
479 parts.append(f"{s['convergent']} convergent")
480 if s.get("clean_no_change"):
481 parts.append(f"{s['clean_no_change']} no-change")
482 print()
483 print(" " + " · ".join(parts) if parts else " (no changes)")
484
485
486 def _run_merge(
487 root: pathlib.Path,
488 branch: str,
489 no_ff: bool,
490 message: str | None,
491 harmony_autoupdate: bool,
492 force: bool,
493 dry_run: bool,
494 strategy: str | None,
495 json_out: bool,
496 on_conflict: str | None = None,
497 history: str | None = None,
498 explain: bool = False,
499 ) -> None:
500 """Execute the merge — called by :func:`run` after autoshelf setup."""
501 elapsed = start_timer()
502
503 # Dry-run never touches the working tree; skip the clean-workdir check.
504 if not dry_run:
505 require_clean_workdir(root, "merge", force=force, json_out=json_out)
506 current_branch = read_current_branch(root)
507 domain = read_domain(root)
508 plugin = resolve_plugin(root)
509
510 protected = get_protected_branches(root)
511 if is_branch_protected(current_branch, protected):
512 _emit_error(
513 json_out,
514 f"Branch '{sanitize_display(current_branch)}' is protected — merge directly into it is not allowed. "
515 "Merge into an integration branch (e.g. dev) and promote via the standard flow.",
516 ExitCode.USER_ERROR,
517 elapsed,
518 )
519
520 if branch == current_branch:
521 _emit_error(json_out, "Cannot merge a branch into itself.", ExitCode.USER_ERROR, elapsed)
522
523 ours_commit_id = get_head_commit_id(root, current_branch)
524 theirs_commit_id = resolve_any_ref(root, branch)
525
526 if theirs_commit_id is None:
527 _emit_error(json_out, f"Branch '{sanitize_display(branch)}' has no commits.", ExitCode.USER_ERROR, elapsed)
528
529 if ours_commit_id is None:
530 _emit_error(json_out, "Current branch has no commits.", ExitCode.USER_ERROR, elapsed)
531
532 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
533
534 # Capture the user-visible strategy name before any normalization.
535 # Cases 1 and 2 emit JSON before the STRATEGY_MAP routing block, so
536 # reported_strategy must be set here.
537 reported_strategy = strategy
538
539 # -------------------------------------------------------------------
540 # Case 1: already up to date
541 # -------------------------------------------------------------------
542 if base_commit_id == theirs_commit_id:
543 if json_out:
544 print(json.dumps(_MergeJsonBase(
545 **make_envelope(elapsed),
546 status="up_to_date",
547 commit_id=ours_commit_id,
548 branch=branch,
549 current_branch=current_branch,
550 base_commit_id=base_commit_id,
551 conflicts=[],
552 files_changed={"added": 0, "modified": 0, "deleted": 0},
553 semver_impact="",
554 strategy=reported_strategy,
555 on_conflict=on_conflict,
556 history=history,
557 dry_run=dry_run,
558 merge_result=_MergeResultDict(
559 status="up_to_date",
560 commit_id=ours_commit_id,
561 strategy=reported_strategy,
562 on_conflict=on_conflict,
563 history=history,
564 conflicts=[],
565 files_changed={"added": 0, "modified": 0, "deleted": 0},
566 semver_impact="",
567 ),
568 )))
569 else:
570 print("Already up to date.")
571 return
572
573 # -------------------------------------------------------------------
574 # Case 2: fast-forward
575 # -------------------------------------------------------------------
576 if base_commit_id == ours_commit_id and not no_ff:
577 theirs_commit = read_commit(root, theirs_commit_id)
578 if theirs_commit is None:
579 print(
580 f"❌ Cannot read commit {theirs_commit_id} for branch "
581 f"'{sanitize_display(branch)}' — aborting to prevent data loss.",
582 file=sys.stderr,
583 )
584 raise SystemExit(ExitCode.INTERNAL_ERROR)
585 ff_snap = read_snapshot(root, theirs_commit.snapshot_id)
586 if ff_snap is None:
587 print(
588 f"❌ Cannot read snapshot {theirs_commit.snapshot_id} for branch "
589 f"'{sanitize_display(branch)}' — aborting to prevent data loss.",
590 file=sys.stderr,
591 )
592 raise SystemExit(ExitCode.INTERNAL_ERROR)
593 ff_manifest: Manifest = ff_snap.manifest
594
595 ours_commit_rec = read_commit(root, ours_commit_id)
596 ours_ff_manifest: Manifest = {}
597 if ours_commit_rec:
598 ours_snap_rec = read_snapshot(root, ours_commit_rec.snapshot_id)
599 if ours_snap_rec:
600 ours_ff_manifest = ours_snap_rec.manifest
601 added, modified, deleted = _diff_stats(ours_ff_manifest, ff_manifest)
602
603 if not dry_run:
604 apply_manifest(root, ours_ff_manifest, ff_manifest)
605 try:
606 validate_branch_name(current_branch)
607 except ValueError as exc:
608 print(
609 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
610 file=sys.stderr,
611 )
612 raise SystemExit(ExitCode.INTERNAL_ERROR)
613 try:
614 write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id)
615 except RefConflictError as exc:
616 print(f"❌ {exc}", file=sys.stderr)
617 raise SystemExit(ExitCode.USER_ERROR)
618 append_reflog(
619 root, current_branch, old_id=ours_commit_id, new_id=theirs_commit_id,
620 author="user",
621 operation=(
622 f"merge: fast-forward {sanitize_display(branch)} "
623 f"→ {sanitize_display(current_branch)}"
624 ),
625 )
626
627 if json_out:
628 _ff_commit_id = theirs_commit_id if not dry_run else None
629 _ff_files = {"added": added, "modified": modified, "deleted": deleted}
630 print(json.dumps(_MergeJsonBase(
631 **make_envelope(elapsed),
632 status="fast_forward",
633 commit_id=_ff_commit_id,
634 branch=branch,
635 current_branch=current_branch,
636 base_commit_id=base_commit_id,
637 conflicts=[],
638 files_changed=_ff_files,
639 semver_impact="",
640 strategy=reported_strategy,
641 on_conflict=on_conflict,
642 history=history,
643 dry_run=dry_run,
644 merge_result=_MergeResultDict(
645 status="fast_forward",
646 commit_id=_ff_commit_id,
647 strategy=reported_strategy,
648 on_conflict=on_conflict,
649 history=history,
650 conflicts=[],
651 files_changed=_ff_files,
652 semver_impact="",
653 ),
654 )))
655 else:
656 if dry_run:
657 print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n")
658 print(
659 f"{_c('Updating', _DIM)} "
660 f"{_c(short_id(ours_commit_id), _YELLOW)}.."
661 f"{_c(short_id(theirs_commit_id), _YELLOW)}"
662 )
663 label = "Would fast-forward" if dry_run else "Fast-forward"
664 print(
665 _c(label, _BOLD)
666 + f" {sanitize_display(branch)} → {sanitize_display(current_branch)}"
667 )
668 _print_file_stats(added, modified, deleted)
669 return
670
671 # Read both branch manifests — needed by Cases 3 and 4.
672 _ours_manifest = get_head_snapshot_manifest(root, current_branch)
673 if _ours_manifest is None:
674 print(
675 f"❌ Cannot read snapshot for branch '{sanitize_display(current_branch)}' "
676 "— aborting to prevent data loss.",
677 file=sys.stderr,
678 )
679 raise SystemExit(ExitCode.INTERNAL_ERROR)
680 ours_manifest: Manifest = _ours_manifest
681
682 _theirs_manifest = get_head_snapshot_manifest(root, branch)
683 if _theirs_manifest is None:
684 print(
685 f"❌ Cannot read snapshot for branch '{sanitize_display(branch)}' "
686 "— aborting to prevent data loss.",
687 file=sys.stderr,
688 )
689 raise SystemExit(ExitCode.INTERNAL_ERROR)
690 theirs_manifest: Manifest = _theirs_manifest
691
692 # Use STRATEGY_MAP as the single source of truth for routing.
693 # Look up the engine; default to recursive if no strategy specified.
694 _engine = STRATEGY_MAP.get(strategy or "recursive")
695
696 # Determine effective resolution: --on-conflict overrides engine default.
697 if on_conflict == "ours":
698 _resolution = "prefer_ours"
699 elif on_conflict == "theirs":
700 _resolution = "prefer_theirs"
701 else:
702 _resolution = _engine.resolution if _engine else "escalate"
703
704 # diff_unit determines execution path. snapshot/replay_* fall through to the
705 # Case 4 three-way path until Phase 6 adds separate execution paths.
706 _diff_unit = _engine.diff_unit if _engine else "three_way"
707
708 # Route prefer_ours/prefer_theirs to Case 3 auto-resolve.
709 # For the case 3 path, map resolution back to a strategy string.
710 if _resolution == "prefer_ours":
711 strategy = "ours"
712 elif _resolution == "prefer_theirs":
713 strategy = "theirs"
714 else:
715 strategy = None # three-way escalate → Case 4
716
717 # -------------------------------------------------------------------
718 # Case 3: auto-resolve — ours / theirs (driven by STRATEGY_MAP resolution)
719 # -------------------------------------------------------------------
720 if strategy in ("ours", "theirs") and not dry_run:
721 base_manifest_strategy: Manifest = {}
722 if base_commit_id:
723 base_commit_rec = read_commit(root, base_commit_id)
724 if base_commit_rec is None:
725 print(
726 f"❌ Cannot read merge base commit {base_commit_id} "
727 "— aborting to prevent data loss.",
728 file=sys.stderr,
729 )
730 raise SystemExit(ExitCode.INTERNAL_ERROR)
731 base_snap_rec = read_snapshot(root, base_commit_rec.snapshot_id)
732 if base_snap_rec is None:
733 print(
734 f"❌ Cannot read snapshot for merge base {base_commit_id} "
735 "— aborting to prevent data loss.",
736 file=sys.stderr,
737 )
738 raise SystemExit(ExitCode.INTERNAL_ERROR)
739 base_manifest_strategy = base_snap_rec.manifest
740
741 from muse.core.merge_engine import MergeEngine
742 _case3_engine = MergeEngine(
743 diff_unit=_diff_unit,
744 resolution="prefer_ours" if strategy == "ours" else "prefer_theirs",
745 )
746 _case3_result = run_merge(
747 base_manifest_strategy, ours_manifest, theirs_manifest,
748 _case3_engine, plugin=plugin, repo_root=root, domain=domain,
749 )
750 chosen_manifest = dict(_case3_result.merged["files"])
751
752 apply_manifest(root, {**ours_manifest, **theirs_manifest}, chosen_manifest)
753
754 committed_at = datetime.datetime.now(datetime.timezone.utc)
755 merge_author = sanitize_provenance(get_config_value("user.handle", root) or "")
756 safe_branch = sanitize_display(branch)
757 merge_msg = message or f"Merge branch '{safe_branch}' (--strategy={strategy})"
758 chosen_dirs = directories_from_manifest(chosen_manifest)
759 strategy_snap_id = hash_snapshot(chosen_manifest, chosen_dirs)
760 strategy_commit_id = hash_commit(
761 parent_ids=[ours_commit_id, theirs_commit_id],
762 snapshot_id=strategy_snap_id,
763 message=merge_msg,
764 committed_at_iso=committed_at.isoformat(),
765 author=merge_author,
766 )
767 write_snapshot(root, SnapshotRecord(snapshot_id=strategy_snap_id, manifest=chosen_manifest, directories=chosen_dirs))
768 write_commit(root, CommitRecord(
769 commit_id=strategy_commit_id,
770 branch=current_branch,
771 snapshot_id=strategy_snap_id,
772 message=merge_msg,
773 committed_at=committed_at,
774 parent_commit_id=ours_commit_id,
775 parent2_commit_id=theirs_commit_id,
776 author=merge_author,
777 ))
778 try:
779 validate_branch_name(current_branch)
780 except ValueError as exc:
781 print(
782 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
783 file=sys.stderr,
784 )
785 raise SystemExit(ExitCode.INTERNAL_ERROR)
786 try:
787 write_branch_ref(root, current_branch, strategy_commit_id, expected_id=ours_commit_id)
788 except RefConflictError as exc:
789 print(f"❌ {exc}", file=sys.stderr)
790 raise SystemExit(ExitCode.USER_ERROR)
791 append_reflog(
792 root, current_branch,
793 old_id=ours_commit_id, new_id=strategy_commit_id,
794 author="user",
795 operation=(
796 f"merge (--strategy={strategy}): "
797 f"{sanitize_display(branch)} → {sanitize_display(current_branch)}"
798 ),
799 )
800 strategy_added, strategy_modified, strategy_deleted = _diff_stats(
801 ours_manifest, chosen_manifest
802 )
803 if json_out:
804 _s3_files = {"added": strategy_added, "modified": strategy_modified, "deleted": strategy_deleted}
805 print(json.dumps(_MergeJsonBase(
806 **make_envelope(elapsed),
807 status="merged",
808 commit_id=strategy_commit_id,
809 branch=branch,
810 current_branch=current_branch,
811 base_commit_id=base_commit_id,
812 conflicts=[],
813 files_changed=_s3_files,
814 semver_impact="",
815 strategy=reported_strategy,
816 on_conflict=on_conflict,
817 history=history,
818 dry_run=False,
819 merge_result=_MergeResultDict(
820 status="merged",
821 commit_id=strategy_commit_id,
822 strategy=reported_strategy,
823 on_conflict=on_conflict,
824 history=history,
825 conflicts=[],
826 files_changed=_s3_files,
827 semver_impact="",
828 ),
829 )))
830 else:
831 print(
832 f"✅ Merged '{sanitize_display(branch)}' into "
833 f"'{sanitize_display(current_branch)}' "
834 f"using strategy '{strategy}' → {strategy_commit_id}"
835 )
836 _print_file_stats(strategy_added, strategy_modified, strategy_deleted)
837 return
838
839 # -------------------------------------------------------------------
840 # Case 4: three-way merge
841 # -------------------------------------------------------------------
842 base_manifest: Manifest = {}
843 if base_commit_id:
844 base_commit = read_commit(root, base_commit_id)
845 if base_commit is None:
846 print(
847 f"❌ Cannot read merge base commit {base_commit_id} "
848 "— aborting to prevent data loss.",
849 file=sys.stderr,
850 )
851 raise SystemExit(ExitCode.INTERNAL_ERROR)
852 base_snap = read_snapshot(root, base_commit.snapshot_id)
853 if base_snap is None:
854 print(
855 f"❌ Cannot read snapshot for merge base {base_commit_id} "
856 "— aborting to prevent data loss.",
857 file=sys.stderr,
858 )
859 raise SystemExit(ExitCode.INTERNAL_ERROR)
860 base_manifest = base_snap.manifest
861
862 merge_debug_log("merge.case4.enter", {
863 "caller": "muse_merge",
864 "current_branch": current_branch,
865 "source_branch": branch,
866 "base_commit_id": base_commit_id,
867 "ours_commit_id": ours_commit_id,
868 "theirs_commit_id": theirs_commit_id,
869 "base_manifest": merge_debug_manifest_summary(base_manifest),
870 "ours_manifest": merge_debug_manifest_summary(ours_manifest),
871 "theirs_manifest": merge_debug_manifest_summary(theirs_manifest),
872 "strategy": strategy,
873 })
874
875 from muse.core.merge_engine import MergeEngine
876 _case4_engine = MergeEngine(diff_unit=_diff_unit, resolution=_resolution)
877 result = run_merge(
878 base_manifest, ours_manifest, theirs_manifest,
879 _case4_engine, plugin=plugin, repo_root=root, domain=domain,
880 )
881 structured = isinstance(plugin, AddressedMergePlugin)
882
883 # Capture pre-harmony conflicts and prepare harmony decisions dict for explain.
884 pre_harmony_conflicts: list[str] = list(result.conflicts)
885 harmony_decisions: dict = {}
886
887 merge_debug_log("merge.case4.result", {
888 "caller": "muse_merge",
889 "is_clean": result.is_clean,
890 "conflicts": result.conflicts,
891 "applied_strategies": result.applied_strategies,
892 "merged_file_count": len(result.merged["files"]),
893 })
894
895 if result.applied_strategies and not json_out:
896 for p, strat in sorted(result.applied_strategies.items()):
897 safe_p = sanitize_display(p)
898 safe_strat = sanitize_display(strat)
899 if strat == "dimension-merge":
900 dim_detail = result.dimension_reports.get(p, {})
901 dim_summary = ", ".join(
902 f"{sanitize_display(d)}={sanitize_display(str(v))}"
903 for d, v in sorted(dim_detail.items())
904 )
905 print(f" ✔ dimension-merge: {safe_p} ({dim_summary})")
906 elif strat != "manual":
907 print(f" ✔ [{safe_strat}] {safe_p}")
908
909 if not result.is_clean:
910 harmony_resolved: Manifest = {}
911 remaining_conflicts = result.conflicts
912
913 if harmony_autoupdate and not dry_run:
914 harmony_resolved, remaining_conflicts = harmony_auto_apply(
915 root,
916 result.conflicts,
917 ours_manifest,
918 theirs_manifest,
919 domain,
920 plugin,
921 explain_decisions=harmony_decisions if explain else None,
922 )
923 if not json_out:
924 for p in sorted(harmony_resolved):
925 print(f" ✔ [harmony] auto-resolved: {sanitize_display(p)}")
926
927 # prefer_ours/prefer_theirs auto-resolves all conflicts — skip conflict reporting.
928 if remaining_conflicts and _resolution in ("prefer_ours", "prefer_theirs"):
929 remaining_conflicts = []
930
931 if remaining_conflicts:
932 if not dry_run:
933 # apply_manifest BEFORE write_merge_state — safe crash ordering.
934 # A crash after apply but before write leaves a dirty tree with no
935 # MERGE_STATE.json (recoverable: re-run merge). A crash after
936 # write_merge_state but before apply leaves MERGE_STATE claiming a
937 # conflict while the workdir is still clean — the user cannot resolve.
938 conflict_file_paths: set[str] = {
939 p.split("::")[0] for p in remaining_conflicts
940 }
941 partial_merged: Manifest = dict(ours_manifest)
942 for path, oid in result.merged["files"].items():
943 if path not in conflict_file_paths:
944 partial_merged[path] = oid
945 apply_manifest(root, ours_manifest, partial_merged)
946
947 # Write Cohen Transform conflict markers into each conflicting
948 # file so the user can see both sides and resolve manually.
949 # Binary files are left at the ours version (same policy as checkout).
950 from muse.core.cohen_transform import three_way_merge_lines
951 from muse.core.object_store import read_object
952 from muse.core.io import write_text_atomic
953
954 def _is_binary(b: bytes) -> bool:
955 return b"\0" in b
956
957 for cpath in conflict_file_paths:
958 ours_oid = ours_manifest.get(cpath)
959 theirs_oid = theirs_manifest.get(cpath)
960 base_oid = base_manifest.get(cpath)
961 if not ours_oid or not theirs_oid:
962 continue
963 ours_bytes = read_object(root, ours_oid) or b""
964 theirs_bytes = read_object(root, theirs_oid) or b""
965 base_bytes = read_object(root, base_oid) if base_oid else b""
966 if _is_binary(ours_bytes) or _is_binary(theirs_bytes) or _is_binary(base_bytes):
967 continue
968 base_lines = base_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
969 ours_lines = ours_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
970 theirs_lines = theirs_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
971 merged_lines, _ = three_way_merge_lines(
972 base_lines, ours_lines, theirs_lines,
973 label_ours=current_branch,
974 label_base="base",
975 label_theirs=branch,
976 )
977 dest = root / cpath
978 dest.parent.mkdir(parents=True, exist_ok=True)
979 write_text_atomic(dest, "".join(merged_lines))
980
981 write_merge_state(
982 root,
983 base_commit=base_commit_id or "",
984 ours_commit=ours_commit_id,
985 theirs_commit=theirs_commit_id,
986 conflict_paths=remaining_conflicts,
987 other_branch=branch,
988 )
989
990 symbol_conflicts: list[_SymbolConflictDict] = []
991 conflict_records_out: list[ConflictDict] = []
992 if result.conflict_records:
993 for rec in result.conflict_records:
994 conflict_records_out.append(rec.to_dict())
995 if structured and rec.addresses:
996 symbol_conflicts.append({
997 "path": sanitize_display(rec.path),
998 "symbol": sanitize_display(",".join(rec.addresses)),
999 "ours": sanitize_display(rec.ours_summary),
1000 "theirs": sanitize_display(rec.theirs_summary),
1001 })
1002
1003 if json_out:
1004 _conflict_list = sorted(remaining_conflicts)
1005 out = _MergeJson(
1006 **make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
1007 status="conflict",
1008 commit_id=None,
1009 branch=branch,
1010 current_branch=current_branch,
1011 base_commit_id=base_commit_id,
1012 conflicts=_conflict_list,
1013 files_changed={"added": 0, "modified": 0, "deleted": 0},
1014 semver_impact="",
1015 strategy=strategy,
1016 on_conflict=on_conflict,
1017 history=history,
1018 dry_run=dry_run,
1019 merge_result=_MergeResultDict(
1020 status="conflict",
1021 commit_id=None,
1022 strategy=strategy,
1023 on_conflict=on_conflict,
1024 history=history,
1025 conflicts=_conflict_list,
1026 files_changed={"added": 0, "modified": 0, "deleted": 0},
1027 semver_impact="",
1028 ),
1029 )
1030 out["conflict_records"] = conflict_records_out
1031 out["symbol_conflicts"] = symbol_conflicts
1032 if explain:
1033 out["explain"] = _build_explain_trace(
1034 requested_strategy=reported_strategy,
1035 on_conflict=on_conflict,
1036 history=history,
1037 diff_unit=_diff_unit,
1038 resolution=_resolution,
1039 base_commit_id=base_commit_id,
1040 base_manifest=base_manifest,
1041 ours_manifest=ours_manifest,
1042 theirs_manifest=theirs_manifest,
1043 result_manifest=result.merged["files"],
1044 pre_harmony_conflicts=pre_harmony_conflicts,
1045 harmony_decisions=harmony_decisions,
1046 harmony_was_run=(harmony_autoupdate and not dry_run),
1047 )
1048 print(json.dumps(out))
1049 else:
1050 if dry_run:
1051 print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n")
1052 print(
1053 f"❌ {'Would have merge' if dry_run else 'Merge'} "
1054 f"conflict in {len(remaining_conflicts)} file(s):",
1055 file=sys.stderr,
1056 )
1057 for p in sorted(remaining_conflicts):
1058 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
1059 if symbol_conflicts:
1060 print(
1061 f"\n Symbol-level conflicts ({len(symbol_conflicts)}):",
1062 file=sys.stderr,
1063 )
1064 for sc in symbol_conflicts[:10]:
1065 print(f" {sc['path']}::{sc['symbol']}", file=sys.stderr)
1066 if len(symbol_conflicts) > 10:
1067 print(
1068 f" … and {len(symbol_conflicts) - 10} more",
1069 file=sys.stderr,
1070 )
1071 if not dry_run:
1072 print(
1073 '\nFix conflicts and run "muse commit" to complete the merge.',
1074 file=sys.stderr,
1075 )
1076 if explain:
1077 _print_explain_trace(_build_explain_trace(
1078 requested_strategy=reported_strategy,
1079 on_conflict=on_conflict,
1080 history=history,
1081 diff_unit=_diff_unit,
1082 resolution=_resolution,
1083 base_commit_id=base_commit_id,
1084 base_manifest=base_manifest,
1085 ours_manifest=ours_manifest,
1086 theirs_manifest=theirs_manifest,
1087 result_manifest=result.merged["files"],
1088 pre_harmony_conflicts=pre_harmony_conflicts,
1089 harmony_decisions=harmony_decisions,
1090 harmony_was_run=(harmony_autoupdate and not dry_run),
1091 ))
1092 raise SystemExit(ExitCode.USER_ERROR)
1093
1094 if not dry_run:
1095 merged_files = dict(result.merged["files"])
1096 merged_files.update(harmony_resolved)
1097 result = MergeResult(
1098 merged=SnapshotManifest(files=merged_files, domain=domain, directories=directories_from_manifest(merged_files)),
1099 conflicts=[],
1100 applied_strategies=result.applied_strategies,
1101 dimension_reports=result.dimension_reports,
1102 op_log=result.op_log,
1103 conflict_records=result.conflict_records,
1104 )
1105
1106 merged_manifest = result.merged["files"]
1107 added, modified, deleted = _diff_stats(ours_manifest, merged_manifest)
1108
1109 if dry_run:
1110 semver_impact = _semver_from_op_log(result.op_log if structured else [])
1111 if json_out:
1112 _dr_files = {"added": added, "modified": modified, "deleted": deleted}
1113 _dr_envelope = _MergeJsonBase(
1114 **make_envelope(elapsed),
1115 status="merged",
1116 commit_id=None,
1117 branch=branch,
1118 current_branch=current_branch,
1119 base_commit_id=base_commit_id,
1120 conflicts=[],
1121 files_changed=_dr_files,
1122 semver_impact=semver_impact,
1123 strategy=reported_strategy,
1124 on_conflict=on_conflict,
1125 history=history,
1126 dry_run=True,
1127 merge_result=_MergeResultDict(
1128 status="merged",
1129 commit_id=None,
1130 strategy=reported_strategy,
1131 on_conflict=on_conflict,
1132 history=history,
1133 conflicts=[],
1134 files_changed=_dr_files,
1135 semver_impact=semver_impact,
1136 ),
1137 )
1138 if explain:
1139 _dr_envelope["explain"] = _build_explain_trace(
1140 requested_strategy=reported_strategy,
1141 on_conflict=on_conflict,
1142 history=history,
1143 diff_unit=_diff_unit,
1144 resolution=_resolution,
1145 base_commit_id=base_commit_id,
1146 base_manifest=base_manifest,
1147 ours_manifest=ours_manifest,
1148 theirs_manifest=theirs_manifest,
1149 result_manifest=result.merged["files"],
1150 pre_harmony_conflicts=pre_harmony_conflicts,
1151 harmony_decisions=harmony_decisions,
1152 harmony_was_run=False,
1153 )
1154 print(json.dumps(_dr_envelope))
1155 else:
1156 print(f"{_c('[dry-run]', _CYAN)} Nothing will be written.\n")
1157 print(f"{_c('Would merge', _BOLD)} by the three-way strategy.")
1158 print(f" {sanitize_display(branch)} → {sanitize_display(current_branch)}")
1159 _print_file_stats(added, modified, deleted)
1160 if semver_impact:
1161 print(f" Proposed semver bump: {_c(semver_impact, _YELLOW)}")
1162 if explain:
1163 _print_explain_trace(_build_explain_trace(
1164 requested_strategy=reported_strategy,
1165 on_conflict=on_conflict,
1166 history=history,
1167 diff_unit=_diff_unit,
1168 resolution=_resolution,
1169 base_commit_id=base_commit_id,
1170 base_manifest=base_manifest,
1171 ours_manifest=ours_manifest,
1172 theirs_manifest=theirs_manifest,
1173 result_manifest=result.merged["files"],
1174 pre_harmony_conflicts=pre_harmony_conflicts,
1175 harmony_decisions=harmony_decisions,
1176 harmony_was_run=False,
1177 ))
1178 return
1179
1180 if not merged_manifest and (ours_manifest or theirs_manifest):
1181 print(
1182 "❌ Internal error: merge produced an empty manifest despite non-empty "
1183 "inputs — aborting to prevent data loss.",
1184 file=sys.stderr,
1185 )
1186 raise SystemExit(ExitCode.INTERNAL_ERROR)
1187
1188 apply_manifest(root, {**ours_manifest, **theirs_manifest}, merged_manifest)
1189
1190 merged_dirs = directories_from_manifest(merged_manifest)
1191 snapshot_id = hash_snapshot(merged_manifest, merged_dirs)
1192 committed_at = datetime.datetime.now(datetime.timezone.utc)
1193 merge_author = sanitize_provenance(get_config_value("user.handle", root) or "")
1194 safe_branch = sanitize_display(branch)
1195 safe_current = sanitize_display(current_branch)
1196
1197 # --history squash/rebase: single-parent commit, no merge parent
1198 # (rebase full commit-by-commit replay is Phase 4 scope)
1199 squash = history in ("squash", "rebase")
1200 if squash:
1201 if history == "rebase":
1202 merge_message = message or f"Rebase branch '{safe_branch}' onto {safe_current}"
1203 else:
1204 merge_message = message or f"Squash merge branch '{safe_branch}' into {safe_current}"
1205 commit_id = hash_commit(
1206 parent_ids=[ours_commit_id],
1207 snapshot_id=snapshot_id,
1208 message=merge_message,
1209 committed_at_iso=committed_at.isoformat(),
1210 author=merge_author,
1211 )
1212 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
1213 write_commit(root, CommitRecord(
1214 commit_id=commit_id,
1215 branch=current_branch,
1216 snapshot_id=snapshot_id,
1217 message=merge_message,
1218 committed_at=committed_at,
1219 parent_commit_id=ours_commit_id,
1220 parent2_commit_id=None,
1221 author=merge_author,
1222 ))
1223 else:
1224 merge_message = message or f"Merge branch '{safe_branch}' into {safe_current}"
1225 commit_id = hash_commit(
1226 parent_ids=[ours_commit_id, theirs_commit_id],
1227 snapshot_id=snapshot_id,
1228 message=merge_message,
1229 committed_at_iso=committed_at.isoformat(),
1230 author=merge_author,
1231 )
1232 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
1233 write_commit(root, CommitRecord(
1234 commit_id=commit_id,
1235 branch=current_branch,
1236 snapshot_id=snapshot_id,
1237 message=merge_message,
1238 committed_at=committed_at,
1239 parent_commit_id=ours_commit_id,
1240 parent2_commit_id=theirs_commit_id,
1241 author=merge_author,
1242 ))
1243 try:
1244 validate_branch_name(current_branch)
1245 except ValueError as exc:
1246 print(
1247 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
1248 file=sys.stderr,
1249 )
1250 raise SystemExit(ExitCode.INTERNAL_ERROR)
1251 try:
1252 write_branch_ref(root, current_branch, commit_id, expected_id=ours_commit_id)
1253 except RefConflictError as exc:
1254 print(f"❌ {exc}", file=sys.stderr)
1255 raise SystemExit(ExitCode.USER_ERROR)
1256
1257 append_reflog(
1258 root, current_branch, old_id=ours_commit_id, new_id=commit_id,
1259 author="user",
1260 operation=f"merge: {sanitize_display(branch)} into {sanitize_display(current_branch)}",
1261 )
1262
1263 if json_out:
1264 _live_files = {"added": added, "modified": modified, "deleted": deleted}
1265 _live_semver = _semver_from_op_log(result.op_log if structured else [])
1266 _live_envelope = _MergeJsonBase(
1267 **make_envelope(elapsed),
1268 status="merged",
1269 commit_id=commit_id,
1270 branch=branch,
1271 current_branch=current_branch,
1272 base_commit_id=base_commit_id,
1273 conflicts=[],
1274 files_changed=_live_files,
1275 semver_impact=_live_semver,
1276 strategy=strategy,
1277 on_conflict=on_conflict,
1278 history=history,
1279 dry_run=False,
1280 merge_result=_MergeResultDict(
1281 status="merged",
1282 commit_id=commit_id,
1283 strategy=strategy,
1284 on_conflict=on_conflict,
1285 history=history,
1286 conflicts=[],
1287 files_changed=_live_files,
1288 semver_impact=_live_semver,
1289 ),
1290 )
1291 if explain:
1292 _live_envelope["explain"] = _build_explain_trace(
1293 requested_strategy=reported_strategy,
1294 on_conflict=on_conflict,
1295 history=history,
1296 diff_unit=_diff_unit,
1297 resolution=_resolution,
1298 base_commit_id=base_commit_id,
1299 base_manifest=base_manifest,
1300 ours_manifest=ours_manifest,
1301 theirs_manifest=theirs_manifest,
1302 result_manifest=merged_manifest,
1303 pre_harmony_conflicts=pre_harmony_conflicts,
1304 harmony_decisions=harmony_decisions,
1305 harmony_was_run=(harmony_autoupdate and not dry_run),
1306 )
1307 print(json.dumps(_live_envelope))
1308 else:
1309 print(f"{_c('Merge', _BOLD)} made by the three-way strategy.")
1310 print(
1311 f" {sanitize_display(branch)} → {sanitize_display(current_branch)}"
1312 f" {_c(short_id(commit_id), _YELLOW)}"
1313 )
1314 _print_file_stats(added, modified, deleted)
1315 if explain:
1316 _print_explain_trace(_build_explain_trace(
1317 requested_strategy=reported_strategy,
1318 on_conflict=on_conflict,
1319 history=history,
1320 diff_unit=_diff_unit,
1321 resolution=_resolution,
1322 base_commit_id=base_commit_id,
1323 base_manifest=base_manifest,
1324 ours_manifest=ours_manifest,
1325 theirs_manifest=theirs_manifest,
1326 result_manifest=merged_manifest,
1327 pre_harmony_conflicts=pre_harmony_conflicts,
1328 harmony_decisions=harmony_decisions,
1329 harmony_was_run=(harmony_autoupdate and not dry_run),
1330 ))
1331
1332 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
1333 """Register the ``muse merge`` subcommand and all its flags."""
1334 parser = subparsers.add_parser(
1335 "merge",
1336 help="Three-way merge a branch into the current branch.",
1337 description=__doc__,
1338 formatter_class=argparse.RawDescriptionHelpFormatter,
1339 )
1340 parser.add_argument(
1341 "branch", nargs="?", default=None,
1342 help="Branch to merge into the current branch.",
1343 )
1344 parser.add_argument(
1345 "--no-ff", action="store_true",
1346 help="Always create a merge commit, even for fast-forward.",
1347 )
1348 parser.add_argument(
1349 "-m", "--message", default=None,
1350 help="Override the merge commit message.",
1351 )
1352 parser.add_argument(
1353 "--harmony-autoupdate", action="store_true", default=True,
1354 dest="harmony_autoupdate",
1355 help="Automatically apply cached harmony resolutions (default: on).",
1356 )
1357 parser.add_argument(
1358 "--no-harmony-autoupdate", action="store_false",
1359 dest="harmony_autoupdate",
1360 help="Disable harmony auto-update.",
1361 )
1362 parser.add_argument(
1363 "--force", "-f", action="store_true",
1364 help="Proceed even with uncommitted changes (data-loss risk).",
1365 )
1366 parser.add_argument(
1367 "--dry-run", "-n", action="store_true", dest="dry_run",
1368 help=(
1369 "Simulate the merge without writing anything. "
1370 "Reports fast-forward, clean merge, or conflicts — including "
1371 "symbol-level conflict detail for structured-merge plugins."
1372 ),
1373 )
1374 parser.add_argument(
1375 "--strategy", "-s",
1376 choices=["recursive", "overlay", "snapshot", "replay", "ours", "theirs"],
1377 default=None,
1378 dest="strategy",
1379 help=(
1380 "Merge strategy (diff_unit). "
1381 "'recursive' — three-way delta merge (default); "
1382 "'overlay' — apply theirs on top of ours (snapshot, no base); "
1383 "'snapshot' — snapshot-level comparison, surface conflicts; "
1384 "'replay' — apply ours' delta onto theirs' state; "
1385 "'ours' / 'theirs' — convenience aliases for recursive + --on-conflict."
1386 ),
1387 )
1388 parser.add_argument(
1389 "--on-conflict",
1390 choices=["escalate", "ours", "theirs"],
1391 default=None,
1392 dest="on_conflict",
1393 help=(
1394 "Resolution policy when a conflict is detected. "
1395 "'escalate' — surface it for human/Harmony resolution (default); "
1396 "'ours' — auto-resolve all conflicts keeping our version; "
1397 "'theirs' — auto-resolve all conflicts keeping their version."
1398 ),
1399 )
1400 parser.add_argument(
1401 "--history",
1402 choices=["merge", "squash", "rebase"],
1403 default=None,
1404 dest="history",
1405 help=(
1406 "History mode. "
1407 "'merge' — two-parent merge commit (default); "
1408 "'squash' — flatten incoming branch into one commit, no merge parent; "
1409 "'rebase' — replay incoming commits linearly on top of ours."
1410 ),
1411 )
1412 parser.add_argument(
1413 "--abort", action="store_true",
1414 help="Abort an in-progress merge and restore the working tree.",
1415 )
1416 parser.add_argument(
1417 "--autoshelf",
1418 dest="autoshelf",
1419 action="store_true",
1420 help=(
1421 "Automatically shelf uncommitted changes before merging and pop "
1422 "them back onto the working tree after the merge completes. "
1423 "Equivalent to running ``muse shelf save`` before merge and "
1424 "``muse shelf pop`` after. "
1425 "Mutually exclusive with --force."
1426 ),
1427 )
1428 parser.add_argument(
1429 "--json", "-j", action="store_true", dest="json_out",
1430 help="Emit machine-readable JSON.",
1431 )
1432 parser.add_argument(
1433 "--explain", action="store_true", default=False, dest="explain",
1434 help=(
1435 "Print a per-path decision trace alongside the merge output. "
1436 "With --json, embeds an 'explain' key in the JSON envelope. "
1437 "With --dry-run, shows what decisions would be made without writing anything. "
1438 "Does not change merge behavior — only adds observation."
1439 ),
1440 )
1441 parser.set_defaults(func=run, autoshelf=False)
1442
1443 def run(args: argparse.Namespace) -> None:
1444 """Three-way merge a branch into the current branch.
1445
1446 Performs a three-way merge (or fast-forward when possible) and emits a
1447 structured result. Pass ``--dry-run`` to simulate without writing.
1448 Pass ``--abort`` to cancel an in-progress merge and restore the working
1449 tree to the pre-merge state.
1450
1451 Agent quickstart
1452 ----------------
1453 ::
1454
1455 muse merge feat/billing --format json
1456 muse merge feat/billing --dry-run --format json
1457 muse merge feat/billing --strategy ours --format json
1458 muse merge --abort --format json
1459
1460 JSON fields
1461 -----------
1462 status ``"merged"``, ``"fast_forward"``, ``"conflict"``, or ``"up_to_date"``.
1463 commit_id New merge commit ID, or ``null`` on conflict or dry-run.
1464 branch Source branch being merged in.
1465 current_branch Target branch being merged into.
1466 base_commit_id Common ancestor commit ID, or ``null``.
1467 conflicts List of paths with unresolved conflicts.
1468 files_changed ``{"added": N, "modified": N, "deleted": N}`` file counts.
1469 semver_impact Proposed semver bump (``"MAJOR"``, ``"MINOR"``, ``"PATCH"``, or ``""``).
1470 strategy Merge strategy used, or ``null``.
1471 dry_run ``true`` when ``--dry-run`` was passed.
1472
1473 Exit codes
1474 ----------
1475 0 Success — merged, fast-forwarded, or already up to date.
1476 1 Conflict detected, or invalid arguments.
1477 3 Internal error — missing snapshot or commit.
1478 """
1479 elapsed = start_timer()
1480 abort: bool = getattr(args, "abort", False)
1481 json_out: bool = args.json_out
1482
1483 if abort:
1484 _run_abort(json_out)
1485 return
1486
1487 branch: str | None = args.branch
1488 if branch is None:
1489 _emit_error(
1490 json_out,
1491 "Usage: muse merge <branch> [options]. To cancel an in-progress merge: muse merge --abort",
1492 ExitCode.USER_ERROR,
1493 elapsed,
1494 )
1495
1496 no_ff: bool = args.no_ff
1497 message: str | None = args.message
1498 harmony_autoupdate: bool = args.harmony_autoupdate
1499 force: bool = args.force
1500 dry_run: bool = getattr(args, "dry_run", False)
1501 strategy: str | None = getattr(args, "strategy", None)
1502 on_conflict: str | None = getattr(args, "on_conflict", None)
1503 history: str | None = getattr(args, "history", None)
1504 autoshelf: bool = getattr(args, "autoshelf", False)
1505 explain: bool = getattr(args, "explain", False)
1506
1507 if autoshelf and force:
1508 _emit_error(json_out, "--autoshelf and --force are mutually exclusive.", ExitCode.USER_ERROR, elapsed)
1509
1510 root = require_repo()
1511
1512 # Autoshelf: save working-tree changes before the merge so require_clean_workdir
1513 # passes, then pop them back in the finally block regardless of outcome.
1514 autoshelf_entry = None
1515 if autoshelf and not dry_run:
1516 current_branch_for_merge = read_current_branch(root)
1517 autoshelf_entry = _shelf_push_programmatic(
1518 root,
1519 name=f"_auto/{sanitize_display(current_branch_for_merge)}",
1520 intent_type="interrupt",
1521 intent=f"autoshelf before merge of {sanitize_display(branch)}",
1522 created_by="muse",
1523 )
1524 if autoshelf_entry is not None and not json_out:
1525 print("autoshelf: saved working directory state", file=sys.stderr)
1526
1527 try:
1528 _run_merge(
1529 root=root,
1530 branch=branch,
1531 no_ff=no_ff,
1532 message=message,
1533 harmony_autoupdate=harmony_autoupdate,
1534 force=force,
1535 dry_run=dry_run,
1536 strategy=strategy,
1537 json_out=json_out,
1538 on_conflict=on_conflict,
1539 history=history,
1540 explain=explain,
1541 )
1542 finally:
1543 if autoshelf_entry is not None:
1544 _apply_autoshelf(root, json_out)
File History 9 commits
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 3 days ago
sha256:7011e00115e9c74d24569fed2caec6a2a6ef8fdb070d3b4715ce06e6633aaa47 feat(merge): add --explain flag with per-path decision trac… Sonnet 4.6 minor 3 days ago
sha256:ecfc7b5d19db951f256942ac0908b53d55a2da37c6cd1e6cf85b4a6088870865 feat(phase6): unified MergeEngine code path via run_merge() Sonnet 4.6 patch 4 days ago
sha256:f02589f8e157757da430d82f35a64c0b7eee5033f6d13076ea395f9942151790 test(phase3): full strategy matrix — 24 SM tests, rebase→linear Sonnet 4.6 4 days ago
sha256:c2e22a54a80ab87150301919c6ac33c0bbafeb39840df86bfbef413147165feb feat: add merge_result sub-object to muse merge --json (del… Sonnet 4.6 4 days ago
sha256:0ab1022a97637701da7c18808e65556a5c774a1572b42e02599ff55efaf69ef4 feat: route merge.py through STRATEGY_MAP; update hub propo… Sonnet 4.6 patch 4 days ago
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1 feat: Phase 1 — MergeEngine class, --on-conflict, --history… Sonnet 4.6 patch 4 days ago
sha256:8c92016d30056bba10f40c739abdcef82334fd27185fe6d7f17bef3418f56131 test: PHANTOM_01-05 regression tests + overlay/state_merge … Sonnet 4.6 patch 4 days ago
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f fix: convergent-edit phantom conflicts in ops_commute + mer… Sonnet 4.6 minor 4 days ago