merge.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 }
32
33 Exit codes::
34
35 0 β€” success (merged, fast-forward, up-to-date, dry-run)
36 1 β€” conflict detected (use ``muse conflicts`` / ``muse checkout --ours/--theirs``)
37 1 β€” invalid arguments (missing branch, bad format)
38 3 β€” internal error (unreadable commit or snapshot)
39 """
40
41 from __future__ import annotations
42
43 import argparse
44 import datetime
45 import json
46 import logging
47 import os
48 import pathlib
49 import sys
50
51 from muse.core.errors import ExitCode
52 from muse.core.merge_engine import (
53 apply_merge,
54 detect_conflicts,
55 diff_snapshots,
56 find_merge_base,
57 read_merge_state,
58 write_merge_state,
59 )
60 from muse.core.merge_hooks import MergeHookResult, get_hook_registry
61 from muse.core.repo import read_repo_id, require_repo
62 from muse.core.rerere import auto_apply as rerere_auto_apply
63 from muse.core.snapshot import compute_commit_id, compute_snapshot_id, directories_from_manifest
64 from muse.core.store import (
65 CommitRecord,
66 Manifest,
67 SnapshotRecord,
68 get_head_commit_id,
69 get_head_snapshot_manifest,
70 read_commit,
71 read_current_branch,
72 read_snapshot,
73 write_branch_ref,
74 write_commit,
75 write_snapshot,
76 )
77 from muse.core.reflog import append_reflog
78 from muse.core.validation import sanitize_display, validate_branch_name
79 from muse.core.workdir import apply_manifest
80 from muse.cli.guard import require_clean_workdir
81 from muse.domain import DomainOp, MergeResult, SnapshotManifest, StructuredMergePlugin
82 from muse.plugins.registry import read_domain, resolve_plugin
83
84 logger = logging.getLogger(__name__)
85
86
87 # ---------------------------------------------------------------------------
88 # Colour helpers β€” respect NO_COLOR and TERM=dumb
89 # ---------------------------------------------------------------------------
90
91 _RESET = "\033[0m"
92 _BOLD = "\033[1m"
93 _DIM = "\033[2m"
94 _GREEN = "\033[32m"
95 _RED = "\033[31m"
96 _YELLOW = "\033[33m"
97 _CYAN = "\033[36m"
98
99
100 def _use_color() -> bool:
101 """Return True only when colour output is appropriate.
102
103 Respects ``NO_COLOR`` (https://no-color.org/), ``TERM=dumb``, and the
104 ``sys.stdout.isatty()`` check so raw ANSI never leaks into pipes or logs.
105 """
106 if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
107 return False
108 return sys.stdout.isatty()
109
110
111 def _c(text: str, *codes: str) -> str:
112 """Wrap *text* in ANSI escape *codes* only when writing to a colour-capable TTY."""
113 if not _use_color():
114 return text
115 return "".join(codes) + text + _RESET
116
117
118 def _diff_stats(
119 old: Manifest,
120 new: Manifest,
121 ) -> tuple[int, int, int]:
122 """Return (added, modified, deleted) file counts between two manifests."""
123 added = sum(1 for k in new if k not in old)
124 deleted = sum(1 for k in old if k not in new)
125 modified = sum(1 for k in new if k in old and old[k] != new[k])
126 return added, modified, deleted
127
128
129 def _hook_result_to_dict(result: MergeHookResult) -> dict[str, object]:
130 """Render a :class:`MergeHookResult` as a JSON-friendly dict.
131
132 Used by the ``--consolidate`` flag to embed the hook outcome under
133 the ``consolidation_hook`` key in ``muse merge --format json``
134 output.
135
136 Args:
137 result: The :class:`MergeHookResult` to serialise.
138
139 Returns:
140 A dict with keys ``hook_name``, ``fired``, ``out_of_band``,
141 ``message``, ``error`` β€” JSON-safe primitive values only.
142 """
143 return {
144 "hook_name": result.hook_name,
145 "fired": result.fired,
146 "out_of_band": result.out_of_band,
147 "message": result.message,
148 "error": result.error,
149 }
150
151
152 def _maybe_run_consolidation_hook(
153 *,
154 consolidate: bool,
155 domain: str,
156 root: pathlib.Path,
157 ours_commit_id: str,
158 theirs_commit_id: str,
159 branch: str,
160 fmt: str,
161 ) -> list[dict[str, object]]:
162 """Fire the registered pre-merge hook for *domain* when --consolidate is set.
163
164 The hook is non-blocking by contract β€” see
165 :mod:`muse.core.merge_hooks`. This helper returns immediately after
166 dispatch and never lets a hook exception escape into the merge
167 command.
168
169 The result is rendered to stdout in text-format runs and returned to
170 the caller as a JSON-friendly list so the caller can embed it in
171 its own JSON output structure.
172
173 Args:
174 consolidate: ``True`` when the user passed ``--consolidate``.
175 If ``False``, this helper is a no-op and returns ``[]``.
176 domain: Repo domain string from
177 :func:`muse.plugins.registry.read_domain`. Today the hook
178 only fires for ``"knowtation"``; other domains receive no
179 hook even when ``--consolidate`` is requested.
180 root: Repository root directory.
181 ours_commit_id: HEAD of the current branch before merge.
182 theirs_commit_id: HEAD of the branch being merged in.
183 branch: Name of the branch being merged in.
184 fmt: ``"text"`` or ``"json"``. Controls whether the helper
185 prints the hook message to stdout (text mode) or returns
186 silently for JSON-mode embedding.
187
188 Returns:
189 A list of JSON-shaped hook result dicts (length 0 when no hook
190 fired, otherwise length 1).
191 """
192 if not consolidate:
193 return []
194 if domain != "knowtation":
195 return []
196 try:
197 results = get_hook_registry().run_pre_merge(
198 root,
199 domain,
200 ours_commit_id,
201 theirs_commit_id,
202 branch,
203 consolidate=True,
204 )
205 except Exception as exc: # noqa: BLE001 β€” must not block merge
206 logger.warning(
207 "merge: consolidation hook dispatch raised %s: %s",
208 type(exc).__name__,
209 exc,
210 )
211 return []
212 rendered = [_hook_result_to_dict(r) for r in results]
213 if fmt == "text":
214 for entry in rendered:
215 mark = "βœ”" if entry["fired"] and not entry["error"] else (
216 "βœ—" if entry["error"] else "Β·"
217 )
218 name = sanitize_display(str(entry["hook_name"]))
219 msg = sanitize_display(str(entry["message"]))
220 print(f" {mark} [{name}] {msg}")
221 if entry["error"]:
222 print(
223 f" consolidation hook error: "
224 f"{sanitize_display(str(entry['error']))}",
225 file=sys.stderr,
226 )
227 return rendered
228
229
230 def _print_file_stats(
231 added: int,
232 modified: int,
233 deleted: int,
234 ) -> None:
235 """Emit the 'N files changed (A added, M modified, D deleted)' summary line."""
236 total = added + modified + deleted
237 if total == 0:
238 return
239 files_word = "file" if total == 1 else "files"
240 parts: list[str] = []
241 if added:
242 parts.append(_c(f"{added} added", _GREEN))
243 if modified:
244 parts.append(f"{modified} modified")
245 if deleted:
246 parts.append(_c(f"{deleted} deleted", _RED))
247 detail = ", ".join(parts)
248 print(f" {_c(str(total), _BOLD)} {files_word} changed ({detail})")
249
250
251 def _semver_from_op_log(op_log: list[DomainOp]) -> str:
252 """Infer a proposed semver bump from a structured merge operation log.
253
254 This is Muse-unique: because Muse tracks named symbols across time (not
255 just line changes), it can tell you whether a merge would introduce a
256 breaking API change (MAJOR), a new feature (MINOR), or a fix (PATCH).
257
258 Heuristic (conservative β€” errs toward higher impact):
259 - Any ``delete`` or ``rename`` on a public symbol β†’ MAJOR
260 - Any ``add`` β†’ MINOR
261 - Any ``modify`` β†’ PATCH
262 - Empty log β†’ "" (cannot determine)
263
264 Args:
265 op_log: List of operation dicts from ``MergeResult.op_log``.
266 Each dict has at least ``"op"`` (add/modify/delete/rename)
267 and ``"symbol"`` keys. Public symbols lack a ``_`` prefix.
268
269 Returns:
270 "MAJOR", "MINOR", "PATCH", or "" when the log is empty.
271 """
272 if not op_log:
273 return ""
274 impact = "PATCH"
275 for op in op_log:
276 op_kind = str(op.get("op", "")).lower()
277 symbol = str(op.get("symbol", ""))
278 is_public = symbol and not symbol.startswith("_")
279 if op_kind in ("delete", "rename") and is_public:
280 return "MAJOR"
281 if op_kind == "add" and is_public and impact != "MAJOR":
282 impact = "MINOR"
283 return impact
284
285
286 def _run_abort(fmt: str) -> None:
287 """Abort an in-progress merge and restore the pre-merge working tree."""
288 root = require_repo()
289
290 merge_state = read_merge_state(root)
291 if merge_state is None:
292 msg = "No merge in progress."
293 if fmt == "json":
294 print(json.dumps({"error": "no_merge_in_progress", "message": msg}))
295 else:
296 print(f"❌ {msg}", file=sys.stderr)
297 raise SystemExit(ExitCode.USER_ERROR)
298
299 ours_commit_id = merge_state.ours_commit or ""
300 if not ours_commit_id:
301 print(
302 "❌ MERGE_STATE has no ours_commit β€” cannot restore working tree.",
303 file=sys.stderr,
304 )
305 raise SystemExit(ExitCode.INTERNAL_ERROR)
306
307 ours_commit = read_commit(root, ours_commit_id)
308 if ours_commit is None:
309 print(
310 f"❌ Cannot restore: pre-merge commit '{ours_commit_id[:12]}' not found.",
311 file=sys.stderr,
312 )
313 raise SystemExit(ExitCode.INTERNAL_ERROR)
314
315 snapshot_id: str | None = ours_commit.snapshot_id or None
316 if snapshot_id:
317 snap = read_snapshot(root, snapshot_id)
318 if snap is not None:
319 apply_manifest(root, snap.manifest)
320
321 merge_state_path = root / ".muse" / "MERGE_STATE.json"
322 merge_state_path.unlink(missing_ok=True)
323
324 if fmt == "json":
325 print(json.dumps({
326 "status": "aborted",
327 "restored_to": ours_commit_id,
328 }))
329 else:
330 print(f"Merge aborted. Working tree restored to {ours_commit_id[:12]}.")
331
332
333 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
334 """Register the ``muse merge`` subcommand and all its flags."""
335 parser = subparsers.add_parser(
336 "merge",
337 help="Three-way merge a branch into the current branch.",
338 description=__doc__,
339 formatter_class=argparse.RawDescriptionHelpFormatter,
340 )
341 parser.add_argument(
342 "branch", nargs="?", default=None,
343 help="Branch to merge into the current branch.",
344 )
345 parser.add_argument(
346 "--no-ff", action="store_true",
347 help="Always create a merge commit, even for fast-forward.",
348 )
349 parser.add_argument(
350 "-m", "--message", default=None,
351 help="Override the merge commit message.",
352 )
353 parser.add_argument(
354 "--rerere-autoupdate", action="store_true", default=True,
355 dest="rerere_autoupdate",
356 help="Automatically apply cached rerere resolutions (default: on).",
357 )
358 parser.add_argument(
359 "--no-rerere-autoupdate", action="store_false",
360 dest="rerere_autoupdate",
361 help="Disable rerere auto-update.",
362 )
363 parser.add_argument(
364 "--force", action="store_true",
365 help="Proceed even with uncommitted changes (data-loss risk).",
366 )
367 parser.add_argument(
368 "--dry-run", action="store_true", dest="dry_run",
369 help=(
370 "Simulate the merge without writing anything. "
371 "Reports fast-forward, clean merge, or conflicts β€” including "
372 "symbol-level conflict detail for structured-merge plugins."
373 ),
374 )
375 parser.add_argument(
376 "--strategy", "-s",
377 choices=["ours", "theirs", "recursive"],
378 default=None,
379 dest="strategy",
380 help=(
381 "Merge strategy. "
382 "'ours' accepts all conflicts by keeping our version; "
383 "'theirs' accepts all conflicts by keeping their version; "
384 "'recursive' (default) performs a three-way merge and stops on conflict."
385 ),
386 )
387 parser.add_argument(
388 "--abort", action="store_true",
389 help="Abort an in-progress merge and restore the working tree.",
390 )
391 parser.add_argument(
392 "--consolidate", action="store_true",
393 dest="consolidate",
394 help=(
395 "After the merge completes, fire the registered pre-merge hook "
396 "for the active domain (knowtation only today). The hook runs "
397 "out-of-band β€” the merge NEVER blocks on its result."
398 ),
399 )
400 parser.add_argument(
401 "--format", "-f", default="text", dest="fmt",
402 help="Output format: text (default) or json.",
403 )
404 parser.add_argument(
405 "--json", action="store_const", const="json", dest="fmt",
406 help="Shorthand for --format json.",
407 )
408 parser.set_defaults(func=run)
409
410
411 def run(args: argparse.Namespace) -> None:
412 """Three-way merge a branch into the current branch.
413
414 All error messages are written to **stderr**; stdout is reserved for
415 structured output only.
416
417 Agents should pass ``--format json`` to receive a stable, machine-readable
418 result with a consistent schema across all outcomes::
419
420 {
421 "status": "merged|fast_forward|conflict|up_to_date",
422 "commit_id": "<sha256> | null",
423 "branch": "<source>",
424 "current_branch": "<target>",
425 "base_commit_id": "<sha256> | null",
426 "conflicts": ["<path>", ...],
427 "files_changed": {"added": N, "modified": N, "deleted": N},
428 "semver_impact": "MAJOR|MINOR|PATCH|",
429 "strategy": "ours|theirs|recursive|null",
430 "dry_run": false
431 }
432
433 Pass ``--dry-run`` to simulate the merge without writing anything.
434 Pass ``--abort`` to cancel an in-progress merge and restore the working
435 tree to the pre-merge state recorded in MERGE_STATE.json.
436
437 Exit codes::
438
439 0 β€” success
440 1 β€” conflict detected, invalid arguments
441 3 β€” internal error (missing snapshot or commit)
442 """
443 abort: bool = getattr(args, "abort", False)
444 fmt: str = args.fmt
445
446 if abort:
447 _run_abort(fmt)
448 return
449
450 branch: str | None = args.branch
451 if branch is None:
452 print("❌ Usage: muse merge <branch> [options]", file=sys.stderr)
453 print(" To cancel an in-progress merge: muse merge --abort", file=sys.stderr)
454 raise SystemExit(ExitCode.USER_ERROR)
455
456 no_ff: bool = args.no_ff
457 message: str | None = args.message
458 rerere_autoupdate: bool = args.rerere_autoupdate
459 force: bool = args.force
460 dry_run: bool = getattr(args, "dry_run", False)
461 strategy: str | None = getattr(args, "strategy", None)
462 consolidate: bool = getattr(args, "consolidate", False)
463
464 if fmt not in ("text", "json"):
465 print(
466 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
467 file=sys.stderr,
468 )
469 raise SystemExit(ExitCode.USER_ERROR)
470
471 root = require_repo()
472 # Dry-run never touches the working tree; skip the clean-workdir check.
473 if not dry_run:
474 require_clean_workdir(root, "merge", force=force)
475 repo_id = read_repo_id(root)
476 current_branch = read_current_branch(root)
477 domain = read_domain(root)
478 plugin = resolve_plugin(root)
479
480 if branch == current_branch:
481 print("❌ Cannot merge a branch into itself.", file=sys.stderr)
482 raise SystemExit(ExitCode.USER_ERROR)
483
484 ours_commit_id = get_head_commit_id(root, current_branch)
485 theirs_commit_id = get_head_commit_id(root, branch)
486
487 if theirs_commit_id is None:
488 print(
489 f"❌ Branch '{sanitize_display(branch)}' has no commits.",
490 file=sys.stderr,
491 )
492 raise SystemExit(ExitCode.USER_ERROR)
493
494 if ours_commit_id is None:
495 print("❌ Current branch has no commits.", file=sys.stderr)
496 raise SystemExit(ExitCode.USER_ERROR)
497
498 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
499
500 # -----------------------------------------------------------------------
501 # Case 1: already up to date
502 # -----------------------------------------------------------------------
503 if base_commit_id == theirs_commit_id:
504 hook_records = _maybe_run_consolidation_hook(
505 consolidate=consolidate,
506 domain=domain,
507 root=root,
508 ours_commit_id=ours_commit_id,
509 theirs_commit_id=theirs_commit_id,
510 branch=branch,
511 fmt=fmt,
512 )
513 if fmt == "json":
514 payload: dict[str, object] = {
515 "status": "up_to_date",
516 "commit_id": ours_commit_id,
517 "branch": branch,
518 "current_branch": current_branch,
519 "base_commit_id": base_commit_id,
520 "conflicts": [],
521 "files_changed": {"added": 0, "modified": 0, "deleted": 0},
522 "semver_impact": "",
523 "strategy": strategy,
524 "dry_run": dry_run,
525 }
526 if hook_records:
527 payload["consolidation_hook"] = hook_records[0]
528 print(json.dumps(payload))
529 else:
530 print("Already up to date.")
531 return
532
533 # -----------------------------------------------------------------------
534 # Case 2: fast-forward
535 # -----------------------------------------------------------------------
536 if base_commit_id == ours_commit_id and not no_ff:
537 theirs_commit = read_commit(root, theirs_commit_id)
538 if theirs_commit is None:
539 print(
540 f"❌ Cannot read commit {theirs_commit_id[:8]} for branch "
541 f"'{sanitize_display(branch)}' β€” aborting to prevent data loss.",
542 file=sys.stderr,
543 )
544 raise SystemExit(ExitCode.INTERNAL_ERROR)
545 ff_snap = read_snapshot(root, theirs_commit.snapshot_id)
546 if ff_snap is None:
547 print(
548 f"❌ Cannot read snapshot {theirs_commit.snapshot_id[:8]} for branch "
549 f"'{sanitize_display(branch)}' β€” aborting to prevent data loss.",
550 file=sys.stderr,
551 )
552 raise SystemExit(ExitCode.INTERNAL_ERROR)
553 ff_manifest: Manifest = ff_snap.manifest
554
555 # Compute diff stats for display only; ours snapshot failure is non-fatal here.
556 ours_commit_rec = read_commit(root, ours_commit_id)
557 ours_ff_manifest: Manifest = {}
558 if ours_commit_rec:
559 ours_snap_rec = read_snapshot(root, ours_commit_rec.snapshot_id)
560 if ours_snap_rec:
561 ours_ff_manifest = ours_snap_rec.manifest
562 added, modified, deleted = _diff_stats(ours_ff_manifest, ff_manifest)
563
564 if not dry_run:
565 apply_manifest(root, ff_manifest)
566 try:
567 validate_branch_name(current_branch)
568 except ValueError as exc:
569 print(
570 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
571 file=sys.stderr,
572 )
573 raise SystemExit(ExitCode.INTERNAL_ERROR)
574 write_branch_ref(root, current_branch, theirs_commit_id)
575 append_reflog(
576 root, current_branch, old_id=ours_commit_id, new_id=theirs_commit_id,
577 author="user",
578 operation=(
579 f"merge: fast-forward {sanitize_display(branch)} "
580 f"β†’ {sanitize_display(current_branch)}"
581 ),
582 )
583
584 ff_hook_records = _maybe_run_consolidation_hook(
585 consolidate=consolidate,
586 domain=domain,
587 root=root,
588 ours_commit_id=ours_commit_id,
589 theirs_commit_id=theirs_commit_id,
590 branch=branch,
591 fmt=fmt,
592 )
593 if fmt == "json":
594 ff_payload: dict[str, object] = {
595 "status": "fast_forward",
596 "commit_id": theirs_commit_id if not dry_run else None,
597 "branch": branch,
598 "current_branch": current_branch,
599 "base_commit_id": base_commit_id,
600 "conflicts": [],
601 "files_changed": {"added": added, "modified": modified, "deleted": deleted},
602 "semver_impact": "",
603 "strategy": strategy,
604 "dry_run": dry_run,
605 }
606 if ff_hook_records:
607 ff_payload["consolidation_hook"] = ff_hook_records[0]
608 print(json.dumps(ff_payload))
609 else:
610 if dry_run:
611 print(_c("[dry-run]", _CYAN) + " Nothing will be written.\n")
612 print(
613 f"{_c('Updating', _DIM)} "
614 f"{_c(ours_commit_id[:8], _YELLOW)}.."
615 f"{_c(theirs_commit_id[:8], _YELLOW)}"
616 )
617 label = "Would fast-forward" if dry_run else "Fast-forward"
618 print(
619 _c(label, _BOLD)
620 + f" {sanitize_display(branch)} β†’ {sanitize_display(current_branch)}"
621 )
622 _print_file_stats(added, modified, deleted)
623 return
624
625 # Read both branch manifests now β€” needed by both the strategy shortcuts
626 # (Case 3) and the full three-way merge (Case 4).
627 # Hard fail on None: silently treating an unreadable snapshot as {} causes
628 # apply_merge({},{},{}) β†’ {} β†’ apply_manifest({}) which deletes all files.
629 _ours_manifest = get_head_snapshot_manifest(root, repo_id, current_branch)
630 if _ours_manifest is None:
631 print(
632 f"❌ Cannot read snapshot for branch '{sanitize_display(current_branch)}' "
633 "β€” aborting to prevent data loss.",
634 file=sys.stderr,
635 )
636 raise SystemExit(ExitCode.INTERNAL_ERROR)
637 ours_manifest: Manifest = _ours_manifest
638
639 _theirs_manifest = get_head_snapshot_manifest(root, repo_id, branch)
640 if _theirs_manifest is None:
641 print(
642 f"❌ Cannot read snapshot for branch '{sanitize_display(branch)}' "
643 "β€” aborting to prevent data loss.",
644 file=sys.stderr,
645 )
646 raise SystemExit(ExitCode.INTERNAL_ERROR)
647 theirs_manifest: Manifest = _theirs_manifest
648
649 # -----------------------------------------------------------------------
650 # Case 3: strategy shortcuts β€” ours / theirs
651 # -----------------------------------------------------------------------
652 # --strategy=ours / --strategy=theirs perform a full three-way merge and
653 # then resolve every conflict automatically by taking the chosen side.
654 # Non-conflicting changes from BOTH sides are always applied β€” only the
655 # conflicting files are decided by the strategy flag.
656 if strategy in ("ours", "theirs") and not dry_run:
657 # Read the merge-base manifest so we can compute per-file change-sets.
658 base_manifest_strategy: Manifest = {}
659 if base_commit_id:
660 base_commit_rec = read_commit(root, base_commit_id)
661 if base_commit_rec is None:
662 print(
663 f"❌ Cannot read merge base commit {base_commit_id[:8]} "
664 "β€” aborting to prevent data loss.",
665 file=sys.stderr,
666 )
667 raise SystemExit(ExitCode.INTERNAL_ERROR)
668 base_snap_rec = read_snapshot(root, base_commit_rec.snapshot_id)
669 if base_snap_rec is None:
670 print(
671 f"❌ Cannot read snapshot for merge base {base_commit_id[:8]} "
672 "β€” aborting to prevent data loss.",
673 file=sys.stderr,
674 )
675 raise SystemExit(ExitCode.INTERNAL_ERROR)
676 base_manifest_strategy = base_snap_rec.manifest
677
678 ours_changed_s = diff_snapshots(base_manifest_strategy, ours_manifest)
679 theirs_changed_s = diff_snapshots(base_manifest_strategy, theirs_manifest)
680 conflict_paths_s = detect_conflicts(
681 ours_changed_s, theirs_changed_s, ours_manifest, theirs_manifest
682 )
683
684 # Build the merged manifest: non-conflicting changes from both sides.
685 chosen_manifest = apply_merge(
686 base_manifest_strategy,
687 ours_manifest,
688 theirs_manifest,
689 ours_changed_s,
690 theirs_changed_s,
691 conflict_paths_s,
692 )
693
694 # Resolve all conflicts by taking the chosen side.
695 side_manifest = ours_manifest if strategy == "ours" else theirs_manifest
696 for path in conflict_paths_s:
697 if path in side_manifest:
698 chosen_manifest[path] = side_manifest[path]
699 else:
700 chosen_manifest.pop(path, None)
701
702 apply_manifest(root, chosen_manifest)
703
704 committed_at = datetime.datetime.now(datetime.timezone.utc)
705 # Sanitize branch names before embedding in commit message (stored on disk).
706 safe_branch = sanitize_display(branch)
707 merge_msg = message or f"Merge branch '{safe_branch}' (--strategy={strategy})"
708 chosen_dirs = directories_from_manifest(chosen_manifest)
709 strategy_snap_id = compute_snapshot_id(chosen_manifest, chosen_dirs)
710 strategy_commit_id = compute_commit_id(
711 parent_ids=[ours_commit_id, theirs_commit_id],
712 snapshot_id=strategy_snap_id,
713 message=merge_msg,
714 committed_at_iso=committed_at.isoformat(),
715 )
716 write_snapshot(root, SnapshotRecord(snapshot_id=strategy_snap_id, manifest=chosen_manifest, directories=chosen_dirs))
717 write_commit(root, CommitRecord(
718 commit_id=strategy_commit_id,
719 repo_id=repo_id,
720 branch=current_branch,
721 snapshot_id=strategy_snap_id,
722 message=merge_msg,
723 committed_at=committed_at,
724 parent_commit_id=ours_commit_id,
725 parent2_commit_id=theirs_commit_id,
726 ))
727 try:
728 validate_branch_name(current_branch)
729 except ValueError as exc:
730 print(
731 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
732 file=sys.stderr,
733 )
734 raise SystemExit(ExitCode.INTERNAL_ERROR)
735 write_branch_ref(root, current_branch, strategy_commit_id)
736 append_reflog(
737 root, current_branch,
738 old_id=ours_commit_id, new_id=strategy_commit_id,
739 author="user",
740 operation=(
741 f"merge (--strategy={strategy}): "
742 f"{sanitize_display(branch)} β†’ {sanitize_display(current_branch)}"
743 ),
744 )
745 strategy_added, strategy_modified, strategy_deleted = _diff_stats(
746 ours_manifest, chosen_manifest
747 )
748 strategy_hook_records = _maybe_run_consolidation_hook(
749 consolidate=consolidate,
750 domain=domain,
751 root=root,
752 ours_commit_id=ours_commit_id,
753 theirs_commit_id=theirs_commit_id,
754 branch=branch,
755 fmt=fmt,
756 )
757 if fmt == "json":
758 strategy_payload: dict[str, object] = {
759 "status": "merged",
760 "commit_id": strategy_commit_id,
761 "branch": branch,
762 "current_branch": current_branch,
763 "base_commit_id": base_commit_id,
764 "conflicts": [],
765 "files_changed": {
766 "added": strategy_added,
767 "modified": strategy_modified,
768 "deleted": strategy_deleted,
769 },
770 "semver_impact": "",
771 "strategy": strategy,
772 "dry_run": False,
773 }
774 if strategy_hook_records:
775 strategy_payload["consolidation_hook"] = strategy_hook_records[0]
776 print(json.dumps(strategy_payload))
777 else:
778 print(
779 f"βœ… Merged '{sanitize_display(branch)}' into "
780 f"'{sanitize_display(current_branch)}' "
781 f"using strategy '{strategy}' β†’ {strategy_commit_id[:8]}"
782 )
783 _print_file_stats(strategy_added, strategy_modified, strategy_deleted)
784 return
785
786 # -----------------------------------------------------------------------
787 # Case 4: three-way merge
788 # -----------------------------------------------------------------------
789 base_manifest: Manifest = {}
790 if base_commit_id:
791 base_commit = read_commit(root, base_commit_id)
792 if base_commit is None:
793 print(
794 f"❌ Cannot read merge base commit {base_commit_id[:8]} "
795 "β€” aborting to prevent data loss.",
796 file=sys.stderr,
797 )
798 raise SystemExit(ExitCode.INTERNAL_ERROR)
799 base_snap = read_snapshot(root, base_commit.snapshot_id)
800 if base_snap is None:
801 print(
802 f"❌ Cannot read snapshot for merge base {base_commit_id[:8]} "
803 "β€” aborting to prevent data loss.",
804 file=sys.stderr,
805 )
806 raise SystemExit(ExitCode.INTERNAL_ERROR)
807 base_manifest = base_snap.manifest
808
809 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
810 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest))
811 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain, directories=directories_from_manifest(theirs_manifest))
812
813 # Prefer operation-level merge when the plugin supports it.
814 # Produces finer-grained conflict detection (sub-file / note level).
815 structured = isinstance(plugin, StructuredMergePlugin)
816 if structured and isinstance(plugin, StructuredMergePlugin):
817 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
818 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
819 result = plugin.merge_ops(
820 base_snap_obj,
821 ours_snap_obj,
822 theirs_snap_obj,
823 ours_delta["ops"],
824 theirs_delta["ops"],
825 repo_root=root,
826 )
827 logger.debug(
828 "merge: used operation-level merge (%s); %d conflict(s)",
829 type(plugin).__name__,
830 len(result.conflicts),
831 )
832 else:
833 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
834
835 # Report any .museattributes auto-resolutions.
836 if result.applied_strategies:
837 for p, strat in sorted(result.applied_strategies.items()):
838 safe_p = sanitize_display(p)
839 safe_strat = sanitize_display(strat)
840 if strat == "dimension-merge":
841 dim_detail = result.dimension_reports.get(p, {})
842 dim_summary = ", ".join(
843 f"{sanitize_display(d)}={sanitize_display(str(v))}"
844 for d, v in sorted(dim_detail.items())
845 )
846 print(f" βœ” dimension-merge: {safe_p} ({dim_summary})")
847 elif strat != "manual":
848 print(f" βœ” [{safe_strat}] {safe_p}")
849
850 if not result.is_clean:
851 rerere_resolved: Manifest = {}
852 remaining_conflicts = result.conflicts
853
854 # Dry-run: skip rerere (it writes preimage files) but still report conflicts.
855 if rerere_autoupdate and not dry_run:
856 rerere_resolved, remaining_conflicts = rerere_auto_apply(
857 root,
858 result.conflicts,
859 ours_manifest,
860 theirs_manifest,
861 domain,
862 plugin,
863 )
864 for p in sorted(rerere_resolved):
865 print(f" βœ” [rerere] auto-resolved: {sanitize_display(p)}")
866
867 if remaining_conflicts:
868 if not dry_run:
869 write_merge_state(
870 root,
871 base_commit=base_commit_id or "",
872 ours_commit=ours_commit_id,
873 theirs_commit=theirs_commit_id,
874 conflict_paths=remaining_conflicts,
875 other_branch=branch,
876 )
877
878 # Apply the non-conflicting portion of the merge to the working
879 # tree NOW β€” before exiting β€” so that theirs-only additions (new
880 # files) and ours-only changes are already visible when the agent
881 # runs `muse checkout --ours/--theirs` to resolve conflicts.
882 conflict_file_paths: set[str] = {
883 p.split("::")[0] for p in remaining_conflicts
884 }
885 partial_merged: Manifest = dict(ours_manifest)
886 for path, oid in result.merged["files"].items():
887 if path not in conflict_file_paths:
888 partial_merged[path] = oid
889 apply_manifest(root, partial_merged)
890
891 # Build symbol-level conflict detail for structured plugins.
892 symbol_conflicts: list[dict[str, str]] = []
893 if structured and result.conflict_records:
894 for rec in result.conflict_records:
895 symbol_conflicts.append({
896 "path": sanitize_display(rec.path),
897 "symbol": sanitize_display(",".join(rec.addresses)),
898 "ours": sanitize_display(rec.ours_summary),
899 "theirs": sanitize_display(rec.theirs_summary),
900 })
901
902 conflict_hook_records = _maybe_run_consolidation_hook(
903 consolidate=consolidate,
904 domain=domain,
905 root=root,
906 ours_commit_id=ours_commit_id,
907 theirs_commit_id=theirs_commit_id,
908 branch=branch,
909 fmt=fmt,
910 )
911 if fmt == "json":
912 conflict_payload: dict[str, object] = {
913 "status": "conflict",
914 "commit_id": None,
915 "branch": branch,
916 "current_branch": current_branch,
917 "base_commit_id": base_commit_id,
918 "conflicts": sorted(remaining_conflicts),
919 "symbol_conflicts": symbol_conflicts,
920 "files_changed": {"added": 0, "modified": 0, "deleted": 0},
921 "semver_impact": "",
922 "strategy": strategy,
923 "dry_run": dry_run,
924 }
925 if conflict_hook_records:
926 conflict_payload["consolidation_hook"] = conflict_hook_records[0]
927 print(json.dumps(conflict_payload))
928 else:
929 if dry_run:
930 print(_c("[dry-run]", _CYAN) + " Nothing will be written.\n")
931 print(
932 f"❌ {'Would have merge' if dry_run else 'Merge'} "
933 f"conflict in {len(remaining_conflicts)} file(s):",
934 file=sys.stderr,
935 )
936 for p in sorted(remaining_conflicts):
937 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
938 if symbol_conflicts:
939 print(
940 f"\n Symbol-level conflicts ({len(symbol_conflicts)}):",
941 file=sys.stderr,
942 )
943 for sc in symbol_conflicts[:10]:
944 print(f" {sc['path']}::{sc['symbol']}", file=sys.stderr)
945 if len(symbol_conflicts) > 10:
946 print(
947 f" … and {len(symbol_conflicts) - 10} more",
948 file=sys.stderr,
949 )
950 if not dry_run:
951 print(
952 '\nFix conflicts and run "muse commit" to complete the merge.',
953 file=sys.stderr,
954 )
955 raise SystemExit(ExitCode.USER_ERROR)
956
957 if not dry_run:
958 # All conflicts resolved by rerere β€” rebuild result and fall through
959 # to the clean-merge path so a merge commit is created normally.
960 merged_files = dict(result.merged["files"])
961 merged_files.update(rerere_resolved)
962 result = MergeResult(
963 merged=SnapshotManifest(files=merged_files, domain=domain, directories=directories_from_manifest(merged_files)),
964 conflicts=[],
965 applied_strategies=result.applied_strategies,
966 dimension_reports=result.dimension_reports,
967 op_log=result.op_log,
968 conflict_records=result.conflict_records,
969 )
970
971 merged_manifest = result.merged["files"]
972 added, modified, deleted = _diff_stats(ours_manifest, merged_manifest)
973
974 # Dry-run: report what would happen and exit without writing.
975 if dry_run:
976 semver_impact = _semver_from_op_log(result.op_log if structured else [])
977 dry_hook_records = _maybe_run_consolidation_hook(
978 consolidate=consolidate,
979 domain=domain,
980 root=root,
981 ours_commit_id=ours_commit_id,
982 theirs_commit_id=theirs_commit_id,
983 branch=branch,
984 fmt=fmt,
985 )
986 if fmt == "json":
987 dry_payload: dict[str, object] = {
988 "status": "merged",
989 "commit_id": None,
990 "branch": branch,
991 "current_branch": current_branch,
992 "base_commit_id": base_commit_id,
993 "conflicts": [],
994 "files_changed": {"added": added, "modified": modified, "deleted": deleted},
995 "semver_impact": semver_impact,
996 "strategy": strategy,
997 "dry_run": True,
998 }
999 if dry_hook_records:
1000 dry_payload["consolidation_hook"] = dry_hook_records[0]
1001 print(json.dumps(dry_payload))
1002 else:
1003 print(_c("[dry-run]", _CYAN) + " Nothing will be written.\n")
1004 print(_c("Would merge", _BOLD) + " by the three-way strategy.")
1005 print(f" {sanitize_display(branch)} β†’ {sanitize_display(current_branch)}")
1006 _print_file_stats(added, modified, deleted)
1007 if semver_impact:
1008 print(f" Proposed semver bump: {_c(semver_impact, _YELLOW)}")
1009 return
1010
1011 # Last-resort guard: refuse to apply an empty manifest when the inputs were
1012 # non-empty. compute_snapshot_id({}) == SHA-256(b"") β€” if we ever reach
1013 # this point with an empty merged_manifest, something went catastrophically
1014 # wrong upstream and we must not delete all tracked files.
1015 if not merged_manifest and (ours_manifest or theirs_manifest):
1016 print(
1017 "❌ Internal error: merge produced an empty manifest despite non-empty "
1018 "inputs β€” aborting to prevent data loss.",
1019 file=sys.stderr,
1020 )
1021 raise SystemExit(ExitCode.INTERNAL_ERROR)
1022
1023 apply_manifest(root, merged_manifest)
1024
1025 merged_dirs = directories_from_manifest(merged_manifest)
1026 snapshot_id = compute_snapshot_id(merged_manifest, merged_dirs)
1027 committed_at = datetime.datetime.now(datetime.timezone.utc)
1028 # Sanitize branch names before embedding in the commit message (stored on disk).
1029 safe_branch = sanitize_display(branch)
1030 safe_current = sanitize_display(current_branch)
1031 merge_message = message or f"Merge branch '{safe_branch}' into {safe_current}"
1032 commit_id = compute_commit_id(
1033 parent_ids=[ours_commit_id, theirs_commit_id],
1034 snapshot_id=snapshot_id,
1035 message=merge_message,
1036 committed_at_iso=committed_at.isoformat(),
1037 )
1038
1039 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
1040 write_commit(root, CommitRecord(
1041 commit_id=commit_id,
1042 repo_id=repo_id,
1043 branch=current_branch,
1044 snapshot_id=snapshot_id,
1045 message=merge_message,
1046 committed_at=committed_at,
1047 parent_commit_id=ours_commit_id,
1048 parent2_commit_id=theirs_commit_id,
1049 ))
1050 try:
1051 validate_branch_name(current_branch)
1052 except ValueError as exc:
1053 print(
1054 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
1055 file=sys.stderr,
1056 )
1057 raise SystemExit(ExitCode.INTERNAL_ERROR)
1058 write_branch_ref(root, current_branch, commit_id)
1059
1060 append_reflog(
1061 root, current_branch, old_id=ours_commit_id, new_id=commit_id,
1062 author="user",
1063 operation=f"merge: {sanitize_display(branch)} into {sanitize_display(current_branch)}",
1064 )
1065
1066 final_hook_records = _maybe_run_consolidation_hook(
1067 consolidate=consolidate,
1068 domain=domain,
1069 root=root,
1070 ours_commit_id=ours_commit_id,
1071 theirs_commit_id=theirs_commit_id,
1072 branch=branch,
1073 fmt=fmt,
1074 )
1075 if fmt == "json":
1076 final_payload: dict[str, object] = {
1077 "status": "merged",
1078 "commit_id": commit_id,
1079 "branch": branch,
1080 "current_branch": current_branch,
1081 "base_commit_id": base_commit_id,
1082 "conflicts": [],
1083 "files_changed": {"added": added, "modified": modified, "deleted": deleted},
1084 "semver_impact": _semver_from_op_log(result.op_log if structured else []),
1085 "strategy": strategy,
1086 "dry_run": False,
1087 }
1088 if final_hook_records:
1089 final_payload["consolidation_hook"] = final_hook_records[0]
1090 print(json.dumps(final_payload))
1091 else:
1092 print(_c("Merge", _BOLD) + " made by the three-way strategy.")
1093 print(
1094 f" {sanitize_display(branch)} β†’ {sanitize_display(current_branch)}"
1095 f" {_c(commit_id[:8], _YELLOW)}"
1096 )
1097 _print_file_stats(added, modified, deleted)