gabriel / muse public
pull.py python
829 lines 31.9 KB Cold
Raw
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f fix: convergent-edit phantom conflicts in ops_commute + mer… Sonnet 4.6 minor ⚠ breaking 100 days ago
1 """``muse pull`` — fetch from a remote and merge into the current branch.
2
3 Combines ``muse fetch`` and ``muse merge`` in a single command:
4
5 1. Downloads commits, snapshots, and blobs from the remote.
6 2. Updates the remote-tracking pointer.
7 3. Performs a three-way merge of the remote branch HEAD into the current branch.
8
9 If the remote branch is already an ancestor of the local HEAD (fast-forward),
10 the local branch ref and working tree are advanced without a merge commit.
11
12 Pass ``--no-merge`` to stop after the fetch step (equivalent to ``muse fetch``).
13 Pass ``--ff-only`` to refuse the pull if a fast-forward is not possible.
14 Pass ``--dry-run`` / ``-n`` to preview what would change without touching the
15 working tree or writing any commits.
16
17 JSON output (``--format json`` / ``--json``) schema::
18
19 {
20 "status": "up_to_date | fast_forward | merged | conflict | fetched | dry_run",
21 "remote": "<name>",
22 "branch": "<remote_branch>",
23 "local_branch": "<local_branch>",
24 "commits_received": <N>,
25 "blobs_written": <N>,
26 "head": "<sha256> | null",
27 "conflict_paths": ["<path>", ...],
28 "dry_run": false
29 }
30
31 Exit codes::
32
33 0 — success (up_to_date, fast_forward, merged, fetched, or dry_run)
34 1 — remote not configured, branch not found, fetch failed, ff-only refused,
35 authentication failure, format error
36 2 — merge conflict (requires manual resolution)
37 """
38
39 import argparse
40 import datetime
41 import json
42 import logging
43 import pathlib
44 import sys
45 import time
46 from typing import TypedDict
47
48 from muse.cli.config import get_signing_identity, get_remote, get_remote_head, get_upstream, set_remote_head
49 from muse.core.version_tags import (
50 VersionTagRecord,
51 write_version_tag,
52 read_version_tag,
53 )
54 from muse.core.semver import parse_semver
55 from muse.core.envelope import EnvelopeJson, make_envelope
56 from muse.core.errors import ExitCode
57 from muse.core.merge_engine import find_merge_base, write_merge_state
58 from muse.core.types import Manifest
59 from muse.core.mpack import apply_mpack
60 from muse.core.repo import require_repo
61 from muse.core.timing import start_timer
62 from muse.core.ids import hash_commit, hash_snapshot
63 from muse.core.snapshot import directories_from_manifest
64 from muse.core.reflog import append_reflog
65 from muse.core.refs import (
66 RefConflictError,
67 get_head_commit_id,
68 read_current_branch,
69 write_branch_ref,
70 )
71 from muse.core.commits import (
72 CommitRecord,
73 get_all_commits,
74 read_commit,
75 write_commit,
76 )
77 from muse.core.snapshots import (
78 SnapshotRecord,
79 get_commit_snapshot_manifest,
80 get_head_snapshot_manifest,
81 read_snapshot,
82 write_snapshot,
83 )
84 from muse.core.transport import (
85 TransportError,
86 make_transport,
87 )
88 from muse.core.workdir import apply_manifest
89 from muse.domain import AddressedMergePlugin, SnapshotManifest
90 from muse.plugins.registry import read_domain, resolve_plugin
91 from muse.core.validation import sanitize_display
92 from muse.core.merge_debug import merge_debug_log, merge_debug_manifest_summary
93
94 logger = logging.getLogger(__name__)
95
96
97 class _PullJson(EnvelopeJson):
98 """Stable JSON schema emitted by ``muse pull --json``."""
99
100 status: str # up_to_date | fast_forward | merged | conflict | fetched | dry_run
101 remote: str
102 branch: str # remote branch pulled from
103 local_branch: str
104 commits_received: int
105 blobs_written: int
106 head: str | None # new local HEAD after the pull, null on conflict/dry_run
107 conflict_paths: list[str]
108 dry_run: bool
109
110
111 class _PullErrorJson(EnvelopeJson):
112 """JSON output for pull fetch-failed error paths."""
113
114 error: str # "fetch_failed"
115 remote: str
116 message: str
117 retryable: bool # True when error is a 503 that exhausted the retry budget
118
119 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
120 """Register the ``muse pull`` subcommand and all its flags."""
121 parser = subparsers.add_parser(
122 "pull",
123 help="Fetch from a remote and merge into the current branch.",
124 description=__doc__,
125 formatter_class=argparse.RawDescriptionHelpFormatter,
126 )
127 parser.add_argument(
128 "remote", nargs="?", default="origin",
129 help="Remote name to pull from (default: origin).",
130 )
131 parser.add_argument(
132 "branch_pos", nargs="?", default=None, metavar="BRANCH",
133 help="Remote branch to pull (default: tracked branch or current branch). Same as --branch.",
134 )
135 parser.add_argument(
136 "--branch", "-b", default=None, dest="branch_flag",
137 help="Remote branch to pull (default: tracked branch or current branch).",
138 )
139 parser.add_argument(
140 "--no-merge", action="store_true", dest="no_merge",
141 help="Only fetch; do not merge into the current branch.",
142 )
143 parser.add_argument(
144 "--ff-only", action="store_true", dest="ff_only",
145 help="Refuse to pull if a fast-forward merge is not possible.",
146 )
147 parser.add_argument(
148 "-n", "--dry-run", action="store_true",
149 help="Preview what would change without modifying the working tree or writing commits.",
150 )
151 parser.add_argument(
152 "-m", "--message", default=None,
153 help="Override the merge commit message.",
154 )
155 parser.add_argument(
156 "--json", "-j", action="store_true", dest="json_out",
157 help="Emit machine-readable JSON instead of human text.",
158 )
159 parser.add_argument(
160 "--retry-timeout",
161 type=float,
162 default=None,
163 dest="retry_budget_s",
164 metavar="SECONDS",
165 help=(
166 "Maximum seconds to wait for the remote to finish preparing fetch data "
167 "(503 Retry-After loop). Default: 120 s or MUSE_FETCH_RETRY_BUDGET_S env."
168 ),
169 )
170 parser.add_argument(
171 "--no-retry",
172 action="store_const",
173 const=0.0,
174 dest="retry_budget_s",
175 help=(
176 "Disable the 503 retry loop — fail immediately on the first 503 response. "
177 "Useful for deterministic CI. Overrides --retry-timeout."
178 ),
179 )
180 parser.set_defaults(func=run)
181
182 def run(args: argparse.Namespace) -> None:
183 """Fetch from a remote and merge into the current branch.
184
185 All progress and error messages go to **stderr**. ``--format json``
186 (or ``--json``) emits a single JSON object on stdout for agent pipelines.
187
188 ``--dry-run`` contacts the remote to discover the current HEAD but does
189 not fetch objects, apply manifests, or write any commits. The JSON output
190 shows exactly what *would* happen (``status``, ``commits_received``
191 estimate, ``conflict_paths`` if a three-way merge would be needed).
192
193 ``--ff-only`` exits with code 1 if the remote HEAD is not a descendant of
194 the local HEAD, preventing silent three-way merge commits.
195
196 JSON schema::
197
198 {
199 "status": "up_to_date | fast_forward | merged | conflict | fetched | dry_run",
200 "remote": "<remote_name>",
201 "branch": "<remote_branch>",
202 "local_branch": "<local_branch>",
203 "commits_received": <N>,
204 "blobs_written": <N>,
205 "head": "<sha256> | null",
206 "conflict_paths": ["<path>", ...],
207 "dry_run": false
208 }
209
210 Exit codes::
211
212 0 — success
213 1 — configuration, network, or ff-only refusal error
214 2 — merge conflict (resolve, then ``muse commit``)
215 """
216 elapsed = start_timer()
217 remote: str = args.remote
218 branch: str | None = (
219 getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None)
220 )
221 no_merge: bool = args.no_merge
222 ff_only: bool = getattr(args, "ff_only", False)
223 dry_run: bool = getattr(args, "dry_run", False)
224 message: str | None = args.message
225 json_out: bool = args.json_out
226 retry_budget_s: float | None = getattr(args, "retry_budget_s", None)
227
228 root = require_repo()
229
230 url = get_remote(remote, root)
231 if url is None:
232 if json_out:
233 print(json.dumps({
234 "error": "remote_not_configured",
235 "remote": remote,
236 "message": f"remote '{remote}' is not configured",
237 "hint": f"muse remote add {remote} <url>",
238 }))
239 print(
240 f"❌ Remote '{sanitize_display(remote)}' is not configured.\n"
241 f" Add it with: muse remote add {sanitize_display(remote)} <url>",
242 file=sys.stderr,
243 )
244 raise SystemExit(ExitCode.USER_ERROR)
245
246 signing = get_signing_identity(root, remote_url=url)
247 current_branch = read_current_branch(root)
248 target_branch = branch or get_upstream(current_branch, root) or current_branch
249
250 transport = make_transport(url)
251
252 # ── Phase 0: discover remote HEAD ────────────────────────────────────────
253 try:
254 info = transport.fetch_remote_info(url, signing)
255 except TransportError as exc:
256 if json_out:
257 print(json.dumps({
258 "error": "remote_unreachable",
259 "remote": remote,
260 "message": str(exc),
261 }))
262 print(
263 f"❌ Cannot reach remote '{sanitize_display(remote)}': "
264 f"{sanitize_display(str(exc))}",
265 file=sys.stderr,
266 )
267 raise SystemExit(ExitCode.INTERNAL_ERROR)
268
269 remote_commit_id = info["branch_heads"].get(target_branch)
270 if remote_commit_id is None:
271 if json_out:
272 print(json.dumps({
273 "error": "branch_not_found",
274 "remote": remote,
275 "branch": target_branch,
276 "available": sorted(info["branch_heads"]),
277 "message": f"branch '{target_branch}' does not exist on remote '{remote}'",
278 }))
279 print(
280 f"❌ Branch '{sanitize_display(target_branch)}' does not exist "
281 f"on remote '{sanitize_display(remote)}'.",
282 file=sys.stderr,
283 )
284 raise SystemExit(ExitCode.USER_ERROR)
285
286 # ── Phase 1: fetch ────────────────────────────────────────────────────────
287 commits_received: int = 0
288 blobs_written: int = 0
289
290 already_known = get_remote_head(remote, target_branch, root)
291 if already_known == remote_commit_id:
292 # We have everything for this commit — skip network fetch entirely.
293 if not json_out:
294 print(
295 f"✅ Fetched 0 commit(s), 0 new blob(s) from "
296 f"{sanitize_display(remote)}/{sanitize_display(target_branch)} "
297 f"({remote_commit_id})"
298 )
299 else:
300 print(
301 f"Fetching {sanitize_display(remote)}/{sanitize_display(target_branch)} …",
302 file=sys.stderr,
303 )
304
305 if dry_run:
306 if json_out:
307 print(json.dumps(_PullJson(
308 **make_envelope(elapsed),
309 status="dry_run",
310 remote=remote,
311 branch=target_branch,
312 local_branch=current_branch,
313 commits_received=0,
314 blobs_written=0,
315 head=None,
316 conflict_paths=[],
317 dry_run=True,
318 )))
319 else:
320 print(
321 f"Would fetch {sanitize_display(remote)}/{sanitize_display(target_branch)} "
322 f"→ {remote_commit_id} (dry run)"
323 )
324 return
325
326 # Build have-list from full local history.
327 have_for_fetch = [c.commit_id for c in get_all_commits(root)]
328
329 t0_fetch = time.perf_counter()
330 try:
331 fetch_result = transport.fetch_mpack(
332 url, signing,
333 want=[remote_commit_id],
334 have=have_for_fetch,
335 retry_budget_s=retry_budget_s,
336 )
337 except TransportError as exc:
338 _is_503 = exc.status_code == 503
339 if _is_503:
340 from muse.core.transport import _resolve_retry_budget
341 _budget = _resolve_retry_budget(retry_budget_s)
342 _msg = f"Remote still preparing fetch data after {int(_budget)}s — try again shortly."
343 else:
344 _msg = str(exc)
345 if json_out:
346 print(json.dumps(_PullErrorJson(
347 **make_envelope(elapsed, exit_code=ExitCode.INTERNAL_ERROR),
348 error="fetch_failed",
349 remote=remote,
350 message=_msg,
351 retryable=_is_503,
352 )))
353 print(f"❌ {sanitize_display(_msg)}", file=sys.stderr)
354 raise SystemExit(ExitCode.INTERNAL_ERROR)
355 t_fetch = time.perf_counter() - t0_fetch
356
357 apply_result = apply_mpack(root, {
358 "commits": fetch_result["commits"],
359 "snapshots": fetch_result["snapshots"],
360 "blobs": fetch_result.get("blobs") or [],
361 })
362 commits_received = apply_result["commits_written"]
363 blobs_written = apply_result["blobs_written"]
364
365 if not (apply_result.get("failed_blobs") or []):
366 set_remote_head(remote, target_branch, remote_commit_id, root)
367
368 print(
369 f"[mpack] fetch/mpack: {t_fetch:.2f}s "
370 f"blobs: {fetch_result['blobs_received']} "
371 f"commits: {commits_received}",
372 file=sys.stderr,
373 )
374
375 # Fetch version tags from remote and store locally (best-effort).
376 try:
377 vtag_result = transport.fetch_version_tags(url, signing)
378 for tag_dict in (vtag_result.get("tags") or []):
379 tag_name = tag_dict.get("tag", "")
380 tag_id = tag_dict.get("tag_id", "")
381 repo_id_val = tag_dict.get("repo_id", "")
382 commit_id_val = tag_dict.get("commit_id", "")
383 created_at_str = tag_dict.get("created_at", "")
384 author_val = tag_dict.get("author", "")
385 message_val = tag_dict.get("message", "")
386 semver_raw = tag_dict.get("semver") or {}
387 if not tag_name:
388 continue
389 try:
390 semver = parse_semver(tag_name)
391 except Exception:
392 try:
393 semver = {
394 "major": int(semver_raw.get("major", 0)),
395 "minor": int(semver_raw.get("minor", 0)),
396 "patch": int(semver_raw.get("patch", 0)),
397 "pre": str(semver_raw.get("pre", "")),
398 "build": str(semver_raw.get("build", "")),
399 }
400 except Exception:
401 continue
402 try:
403 created_at = datetime.datetime.fromisoformat(created_at_str)
404 except Exception:
405 created_at = datetime.datetime.now(datetime.timezone.utc)
406 record = VersionTagRecord(
407 tag_id=tag_id,
408 repo_id=repo_id_val,
409 tag=tag_name,
410 semver=semver,
411 commit_id=commit_id_val,
412 created_at=created_at,
413 author=author_val,
414 message=message_val,
415 )
416 write_version_tag(root, record)
417 except Exception as _vtag_exc:
418 logger.debug("version-tag fetch failed (non-fatal): %s", _vtag_exc)
419
420 if not json_out:
421 print(
422 f"✅ Fetched {commits_received} commit(s), {blobs_written} new blob(s) "
423 f"from {sanitize_display(remote)}/{sanitize_display(target_branch)} "
424 f"({remote_commit_id})"
425 )
426
427 if no_merge:
428 if json_out:
429 print(json.dumps(_PullJson(
430 **make_envelope(elapsed),
431 status="fetched",
432 remote=remote,
433 branch=target_branch,
434 local_branch=current_branch,
435 commits_received=commits_received,
436 blobs_written=blobs_written,
437 head=remote_commit_id,
438 conflict_paths=[],
439 dry_run=dry_run,
440 )))
441 return
442
443 # ── Phase 3: merge ────────────────────────────────────────────────────────
444 ours_commit_id = get_head_commit_id(root, current_branch)
445 theirs_commit_id = remote_commit_id
446
447 if ours_commit_id is None:
448 # No local commits yet — bootstrap: advance HEAD to the remote commit.
449 # Apply the manifest BEFORE writing the branch ref so a crash between
450 # the two leaves the ref consistent with the working tree.
451 if not dry_run:
452 theirs_commit = read_commit(root, theirs_commit_id)
453 if not theirs_commit:
454 print(
455 f"❌ Pull aborted: commit {theirs_commit_id} was fetched but "
456 "is not readable from the local store (corrupt or hash mismatch). "
457 "Run `muse verify-pack` to audit the store.",
458 file=sys.stderr,
459 )
460 raise SystemExit(ExitCode.INTERNAL_ERROR)
461 snap = read_snapshot(root, theirs_commit.snapshot_id)
462 if snap is None:
463 print(
464 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
465 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
466 "Run `muse verify-pack` to audit the store.",
467 file=sys.stderr,
468 )
469 raise SystemExit(ExitCode.INTERNAL_ERROR)
470
471 apply_manifest(root, {}, snap.manifest)
472 try:
473 write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id)
474 except RefConflictError as exc:
475 print(f"❌ {exc}", file=sys.stderr)
476 raise SystemExit(ExitCode.USER_ERROR)
477 try:
478 append_reflog(
479 root, current_branch,
480 old_id=ours_commit_id,
481 new_id=theirs_commit_id,
482 author="user",
483 operation=f"pull: fetched {remote}/{target_branch}",
484 )
485 except Exception as _rl_exc:
486 logger.warning("⚠️ reflog: failed to record pull: %s", _rl_exc)
487 if json_out:
488 print(json.dumps(_PullJson(
489 **make_envelope(elapsed),
490 status="fast_forward",
491 remote=remote,
492 branch=target_branch,
493 local_branch=current_branch,
494 commits_received=commits_received,
495 blobs_written=blobs_written,
496 head=None if dry_run else theirs_commit_id,
497 conflict_paths=[],
498 dry_run=dry_run,
499 )))
500 else:
501 suffix = " (dry run)" if dry_run else ""
502 print(
503 f"✅ Initialised {sanitize_display(current_branch)} "
504 f"at {theirs_commit_id}{suffix}"
505 )
506 return
507
508 if ours_commit_id == theirs_commit_id:
509 if json_out:
510 print(json.dumps(_PullJson(
511 **make_envelope(elapsed),
512 status="up_to_date",
513 remote=remote,
514 branch=target_branch,
515 local_branch=current_branch,
516 commits_received=commits_received,
517 blobs_written=blobs_written,
518 head=ours_commit_id,
519 conflict_paths=[],
520 dry_run=dry_run,
521 )))
522 else:
523 print("Already up to date.", file=sys.stderr)
524 return
525
526 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
527
528 if base_commit_id == theirs_commit_id:
529 # Local is already ahead of remote — nothing to pull.
530 if json_out:
531 print(json.dumps(_PullJson(
532 **make_envelope(elapsed),
533 status="up_to_date",
534 remote=remote,
535 branch=target_branch,
536 local_branch=current_branch,
537 commits_received=commits_received,
538 blobs_written=blobs_written,
539 head=ours_commit_id,
540 conflict_paths=[],
541 dry_run=dry_run,
542 )))
543 else:
544 print("Already up to date.", file=sys.stderr)
545 return
546
547 # Fast-forward: remote is a direct descendant of local HEAD.
548 if base_commit_id == ours_commit_id:
549 if not dry_run:
550 theirs_commit = read_commit(root, theirs_commit_id)
551 if not theirs_commit:
552 print(
553 f"❌ Pull aborted: commit {theirs_commit_id} was fetched but "
554 "is not readable from the local store (corrupt or hash mismatch). "
555 "Run `muse verify-pack` to audit the store.",
556 file=sys.stderr,
557 )
558 raise SystemExit(ExitCode.INTERNAL_ERROR)
559 snap = read_snapshot(root, theirs_commit.snapshot_id)
560 if snap is None:
561 print(
562 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
563 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
564 "Run `muse verify-pack` to audit the store.",
565 file=sys.stderr,
566 )
567 raise SystemExit(ExitCode.INTERNAL_ERROR)
568 # Apply manifest BEFORE advancing the branch pointer so a
569 # crash between the two leaves the ref consistent with the tree.
570
571 ours_ff_manifest = get_commit_snapshot_manifest(root, ours_commit_id) or {}
572 apply_manifest(root, ours_ff_manifest, snap.manifest)
573 try:
574 write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id)
575 except RefConflictError as exc:
576 print(f"❌ {exc}", file=sys.stderr)
577 raise SystemExit(ExitCode.USER_ERROR)
578 try:
579 append_reflog(
580 root, current_branch,
581 old_id=ours_commit_id,
582 new_id=theirs_commit_id,
583 author="user",
584 operation=f"pull: fetched {remote}/{target_branch}",
585 )
586 except Exception as _rl_exc:
587 logger.warning("⚠️ reflog: failed to record pull: %s", _rl_exc)
588 if json_out:
589 print(json.dumps(_PullJson(
590 **make_envelope(elapsed),
591 status="fast_forward",
592 remote=remote,
593 branch=target_branch,
594 local_branch=current_branch,
595 commits_received=commits_received,
596 blobs_written=blobs_written,
597 head=None if dry_run else theirs_commit_id,
598 conflict_paths=[],
599 dry_run=dry_run,
600 )))
601 else:
602 suffix = " (dry run)" if dry_run else ""
603 print(
604 f"Fast-forward {sanitize_display(current_branch)} to "
605 f"{theirs_commit_id} "
606 f"({sanitize_display(remote)}/{sanitize_display(target_branch)}){suffix}"
607 )
608 return
609
610 # Branches have diverged — three-way merge required.
611 if ff_only:
612 if json_out:
613 print(json.dumps({
614 "error": "ff_only_refused",
615 "remote": remote,
616 "branch": target_branch,
617 "local_branch": current_branch,
618 "message": f"{remote}/{target_branch} has diverged from {current_branch} — fast-forward not possible",
619 "hint": "remove --ff-only to allow a merge commit",
620 }))
621 print(
622 f"❌ Pull aborted: {sanitize_display(remote)}/{sanitize_display(target_branch)} "
623 f"has diverged from {sanitize_display(current_branch)}.\n"
624 f" Fast-forward not possible. Remove --ff-only to allow a merge commit.",
625 file=sys.stderr,
626 )
627 raise SystemExit(ExitCode.USER_ERROR)
628
629 domain = read_domain(root)
630 plugin = resolve_plugin(root)
631
632 ours_manifest = get_head_snapshot_manifest(root, current_branch) or {}
633 theirs_commit = read_commit(root, theirs_commit_id)
634 if not theirs_commit:
635 print(
636 f"❌ Pull aborted: commit {theirs_commit_id} is not readable from "
637 "the local store (corrupt or hash mismatch). "
638 "Run `muse verify-pack` to audit the store.",
639 file=sys.stderr,
640 )
641 raise SystemExit(ExitCode.INTERNAL_ERROR)
642 theirs_snap = read_snapshot(root, theirs_commit.snapshot_id)
643 if theirs_snap is None:
644 print(
645 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
646 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
647 "A merge with a missing remote snapshot would treat all remote files "
648 "as deleted. Run `muse verify-pack` to audit the store.",
649 file=sys.stderr,
650 )
651 raise SystemExit(ExitCode.INTERNAL_ERROR)
652 theirs_manifest = dict(theirs_snap.manifest)
653
654 base_manifest: Manifest = {}
655 if base_commit_id:
656 base_commit = read_commit(root, base_commit_id)
657 if base_commit:
658 base_snap = read_snapshot(root, base_commit.snapshot_id)
659 if base_snap:
660 base_manifest = dict(base_snap.manifest)
661
662 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
663 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest))
664 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain, directories=directories_from_manifest(theirs_manifest))
665
666 merge_debug_log("pull.merge.enter", {
667 "caller": "muse_pull",
668 "current_branch": current_branch,
669 "remote": remote,
670 "remote_branch": target_branch,
671 "base_commit_id": base_commit_id,
672 "ours_commit_id": ours_commit_id,
673 "theirs_commit_id": theirs_commit_id,
674 "base_manifest": merge_debug_manifest_summary(base_manifest),
675 "ours_manifest": merge_debug_manifest_summary(ours_manifest),
676 "theirs_manifest": merge_debug_manifest_summary(theirs_manifest),
677 })
678
679 if isinstance(plugin, AddressedMergePlugin):
680 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
681 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
682 merge_debug_log("pull.merge.ops", {
683 "caller": "muse_pull",
684 "ours_ops": ours_delta["ops"],
685 "theirs_ops": theirs_delta["ops"],
686 })
687 result = plugin.merge_ops(
688 base_snap_obj,
689 ours_snap_obj,
690 theirs_snap_obj,
691 ours_delta["ops"],
692 theirs_delta["ops"],
693 repo_root=root,
694 )
695 else:
696 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
697
698 merge_debug_log("pull.merge.result", {
699 "caller": "muse_pull",
700 "is_clean": result.is_clean,
701 "conflicts": result.conflicts,
702 "applied_strategies": result.applied_strategies,
703 "merged_file_count": len(result.merged["files"]),
704 })
705
706 if not json_out and result.applied_strategies:
707 for p, strategy in sorted(result.applied_strategies.items()):
708 if strategy != "manual":
709 print(f" ✔ [{strategy}] {p}", file=sys.stderr)
710
711 if not result.is_clean:
712 if not dry_run:
713 write_merge_state(
714 root,
715 base_commit=base_commit_id or "",
716 ours_commit=ours_commit_id,
717 theirs_commit=theirs_commit_id,
718 conflict_paths=result.conflicts,
719 other_branch=f"{remote}/{target_branch}",
720 )
721 conflict_paths = sorted(result.conflicts)
722 if json_out:
723 print(json.dumps(_PullJson(
724 **make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
725 status="conflict",
726 remote=remote,
727 branch=target_branch,
728 local_branch=current_branch,
729 commits_received=commits_received,
730 blobs_written=blobs_written,
731 head=None,
732 conflict_paths=conflict_paths,
733 dry_run=dry_run,
734 )))
735 else:
736 suffix = " (dry run — no state written)" if dry_run else ""
737 print(f"❌ Merge conflict in {len(conflict_paths)} file(s){suffix}:", file=sys.stderr)
738 for p in conflict_paths:
739 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
740 if not dry_run:
741 print('\nFix conflicts and run "muse commit" to complete the merge.', file=sys.stderr)
742 raise SystemExit(ExitCode.USER_ERROR)
743
744 merged_manifest = result.merged["files"]
745
746 if dry_run:
747 if json_out:
748 print(json.dumps(_PullJson(
749 **make_envelope(elapsed),
750 status="dry_run",
751 remote=remote,
752 branch=target_branch,
753 local_branch=current_branch,
754 commits_received=commits_received,
755 blobs_written=blobs_written,
756 head=None,
757 conflict_paths=[],
758 dry_run=True,
759 )))
760 else:
761 print(
762 f"Would merge {sanitize_display(remote)}/{sanitize_display(target_branch)} "
763 f"into {sanitize_display(current_branch)} (dry run)"
764 )
765 return
766
767
768 apply_manifest(root, {**ours_manifest, **theirs_manifest}, merged_manifest)
769
770 merged_dirs = directories_from_manifest(merged_manifest)
771 snapshot_id = hash_snapshot(merged_manifest, merged_dirs)
772 committed_at = datetime.datetime.now(datetime.timezone.utc)
773 merge_message = (
774 message
775 or f"Merge {remote}/{target_branch} into {current_branch}"
776 )
777 commit_id = hash_commit(
778 parent_ids=[ours_commit_id, theirs_commit_id],
779 snapshot_id=snapshot_id,
780 message=merge_message,
781 committed_at_iso=committed_at.isoformat(),
782 )
783 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
784 write_commit(
785 root,
786 CommitRecord(
787 commit_id=commit_id,
788 branch=current_branch,
789 snapshot_id=snapshot_id,
790 message=merge_message,
791 committed_at=committed_at,
792 parent_commit_id=ours_commit_id,
793 parent2_commit_id=theirs_commit_id,
794 ),
795 )
796 try:
797 write_branch_ref(root, current_branch, commit_id, expected_id=ours_commit_id)
798 except RefConflictError as exc:
799 print(f"❌ {exc}", file=sys.stderr)
800 raise SystemExit(ExitCode.USER_ERROR)
801 try:
802 append_reflog(
803 root, current_branch,
804 old_id=ours_commit_id,
805 new_id=commit_id,
806 author="user",
807 operation=f"pull: merged {remote}/{target_branch}",
808 )
809 except Exception as _rl_exc:
810 logger.warning("⚠️ reflog: failed to record pull: %s", _rl_exc)
811
812 if json_out:
813 print(json.dumps(_PullJson(
814 **make_envelope(elapsed),
815 status="merged",
816 remote=remote,
817 branch=target_branch,
818 local_branch=current_branch,
819 commits_received=commits_received,
820 blobs_written=blobs_written,
821 head=commit_id,
822 conflict_paths=[],
823 dry_run=False,
824 )))
825 else:
826 print(
827 f"✅ Merged {sanitize_display(remote)}/{sanitize_display(target_branch)} "
828 f"into {sanitize_display(current_branch)} ({commit_id})"
829 )
File History 1 commit
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f fix: convergent-edit phantom conflicts in ops_commute + mer… Sonnet 4.6 minor 100 days ago