gabriel / muse public
pull.py python
700 lines 26.8 KB
Raw
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f fix: convergent-edit phantom conflicts in ops_commute + mer… Sonnet 4.6 minor ⚠ breaking 9 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.envelope import EnvelopeJson, make_envelope
50 from muse.core.errors import ExitCode
51 from muse.core.merge_engine import find_merge_base, write_merge_state
52 from muse.core.types import Manifest
53 from muse.core.mpack import apply_mpack
54 from muse.core.repo import require_repo
55 from muse.core.timing import start_timer
56 from muse.core.ids import hash_commit, hash_snapshot
57 from muse.core.snapshot import directories_from_manifest
58 from muse.core.refs import (
59 RefConflictError,
60 get_head_commit_id,
61 read_current_branch,
62 write_branch_ref,
63 )
64 from muse.core.commits import (
65 CommitRecord,
66 get_all_commits,
67 read_commit,
68 write_commit,
69 )
70 from muse.core.snapshots import (
71 SnapshotRecord,
72 get_commit_snapshot_manifest,
73 get_head_snapshot_manifest,
74 read_snapshot,
75 write_snapshot,
76 )
77 from muse.core.transport import (
78 TransportError,
79 make_transport,
80 )
81 from muse.core.workdir import apply_manifest
82 from muse.domain import AddressedMergePlugin, SnapshotManifest
83 from muse.plugins.registry import read_domain, resolve_plugin
84 from muse.core.validation import sanitize_display
85 from muse.core.merge_debug import merge_debug_log, merge_debug_manifest_summary
86
87 logger = logging.getLogger(__name__)
88
89
90 class _PullJson(EnvelopeJson):
91 """Stable JSON schema emitted by ``muse pull --json``."""
92
93 status: str # up_to_date | fast_forward | merged | conflict | fetched | dry_run
94 remote: str
95 branch: str # remote branch pulled from
96 local_branch: str
97 commits_received: int
98 blobs_written: int
99 head: str | None # new local HEAD after the pull, null on conflict/dry_run
100 conflict_paths: list[str]
101 dry_run: bool
102
103 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
104 """Register the ``muse pull`` subcommand and all its flags."""
105 parser = subparsers.add_parser(
106 "pull",
107 help="Fetch from a remote and merge into the current branch.",
108 description=__doc__,
109 formatter_class=argparse.RawDescriptionHelpFormatter,
110 )
111 parser.add_argument(
112 "remote", nargs="?", default="origin",
113 help="Remote name to pull from (default: origin).",
114 )
115 parser.add_argument(
116 "branch_pos", nargs="?", default=None, metavar="BRANCH",
117 help="Remote branch to pull (default: tracked branch or current branch). Same as --branch.",
118 )
119 parser.add_argument(
120 "--branch", "-b", default=None, dest="branch_flag",
121 help="Remote branch to pull (default: tracked branch or current branch).",
122 )
123 parser.add_argument(
124 "--no-merge", action="store_true", dest="no_merge",
125 help="Only fetch; do not merge into the current branch.",
126 )
127 parser.add_argument(
128 "--ff-only", action="store_true", dest="ff_only",
129 help="Refuse to pull if a fast-forward merge is not possible.",
130 )
131 parser.add_argument(
132 "-n", "--dry-run", action="store_true",
133 help="Preview what would change without modifying the working tree or writing commits.",
134 )
135 parser.add_argument(
136 "-m", "--message", default=None,
137 help="Override the merge commit message.",
138 )
139 parser.add_argument(
140 "--json", "-j", action="store_true", dest="json_out",
141 help="Emit machine-readable JSON instead of human text.",
142 )
143 parser.set_defaults(func=run)
144
145 def run(args: argparse.Namespace) -> None:
146 """Fetch from a remote and merge into the current branch.
147
148 All progress and error messages go to **stderr**. ``--format json``
149 (or ``--json``) emits a single JSON object on stdout for agent pipelines.
150
151 ``--dry-run`` contacts the remote to discover the current HEAD but does
152 not fetch objects, apply manifests, or write any commits. The JSON output
153 shows exactly what *would* happen (``status``, ``commits_received``
154 estimate, ``conflict_paths`` if a three-way merge would be needed).
155
156 ``--ff-only`` exits with code 1 if the remote HEAD is not a descendant of
157 the local HEAD, preventing silent three-way merge commits.
158
159 JSON schema::
160
161 {
162 "status": "up_to_date | fast_forward | merged | conflict | fetched | dry_run",
163 "remote": "<remote_name>",
164 "branch": "<remote_branch>",
165 "local_branch": "<local_branch>",
166 "commits_received": <N>,
167 "blobs_written": <N>,
168 "head": "<sha256> | null",
169 "conflict_paths": ["<path>", ...],
170 "dry_run": false
171 }
172
173 Exit codes::
174
175 0 — success
176 1 — configuration, network, or ff-only refusal error
177 2 — merge conflict (resolve, then ``muse commit``)
178 """
179 elapsed = start_timer()
180 remote: str = args.remote
181 branch: str | None = (
182 getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None)
183 )
184 no_merge: bool = args.no_merge
185 ff_only: bool = getattr(args, "ff_only", False)
186 dry_run: bool = getattr(args, "dry_run", False)
187 message: str | None = args.message
188 json_out: bool = args.json_out
189
190 root = require_repo()
191
192 url = get_remote(remote, root)
193 if url is None:
194 if json_out:
195 print(json.dumps({
196 "error": "remote_not_configured",
197 "remote": remote,
198 "message": f"remote '{remote}' is not configured",
199 "hint": f"muse remote add {remote} <url>",
200 }))
201 print(
202 f"❌ Remote '{sanitize_display(remote)}' is not configured.\n"
203 f" Add it with: muse remote add {sanitize_display(remote)} <url>",
204 file=sys.stderr,
205 )
206 raise SystemExit(ExitCode.USER_ERROR)
207
208 signing = get_signing_identity(root, remote_url=url)
209 current_branch = read_current_branch(root)
210 target_branch = branch or get_upstream(current_branch, root) or current_branch
211
212 transport = make_transport(url)
213
214 # ── Phase 0: discover remote HEAD ────────────────────────────────────────
215 try:
216 info = transport.fetch_remote_info(url, signing)
217 except TransportError as exc:
218 if json_out:
219 print(json.dumps({
220 "error": "remote_unreachable",
221 "remote": remote,
222 "message": str(exc),
223 }))
224 print(
225 f"❌ Cannot reach remote '{sanitize_display(remote)}': "
226 f"{sanitize_display(str(exc))}",
227 file=sys.stderr,
228 )
229 raise SystemExit(ExitCode.INTERNAL_ERROR)
230
231 remote_commit_id = info["branch_heads"].get(target_branch)
232 if remote_commit_id is None:
233 if json_out:
234 print(json.dumps({
235 "error": "branch_not_found",
236 "remote": remote,
237 "branch": target_branch,
238 "available": sorted(info["branch_heads"]),
239 "message": f"branch '{target_branch}' does not exist on remote '{remote}'",
240 }))
241 print(
242 f"❌ Branch '{sanitize_display(target_branch)}' does not exist "
243 f"on remote '{sanitize_display(remote)}'.",
244 file=sys.stderr,
245 )
246 raise SystemExit(ExitCode.USER_ERROR)
247
248 # ── Phase 1: fetch ────────────────────────────────────────────────────────
249 commits_received: int = 0
250 blobs_written: int = 0
251
252 already_known = get_remote_head(remote, target_branch, root)
253 if already_known == remote_commit_id:
254 # We have everything for this commit — skip network fetch entirely.
255 if not json_out:
256 print(
257 f"✅ Fetched 0 commit(s), 0 new blob(s) from "
258 f"{sanitize_display(remote)}/{sanitize_display(target_branch)} "
259 f"({remote_commit_id})"
260 )
261 else:
262 print(
263 f"Fetching {sanitize_display(remote)}/{sanitize_display(target_branch)} …",
264 file=sys.stderr,
265 )
266
267 if dry_run:
268 if json_out:
269 print(json.dumps(_PullJson(
270 **make_envelope(elapsed),
271 status="dry_run",
272 remote=remote,
273 branch=target_branch,
274 local_branch=current_branch,
275 commits_received=0,
276 blobs_written=0,
277 head=None,
278 conflict_paths=[],
279 dry_run=True,
280 )))
281 else:
282 print(
283 f"Would fetch {sanitize_display(remote)}/{sanitize_display(target_branch)} "
284 f"→ {remote_commit_id} (dry run)"
285 )
286 return
287
288 # Build have-list from full local history.
289 have_for_fetch = [c.commit_id for c in get_all_commits(root)]
290
291 t0_fetch = time.perf_counter()
292 try:
293 fetch_result = transport.fetch_mpack(
294 url, signing,
295 want=[remote_commit_id],
296 have=have_for_fetch,
297 )
298 except TransportError as exc:
299 print(f"❌ Fetch failed: {sanitize_display(str(exc))}", file=sys.stderr)
300 raise SystemExit(ExitCode.INTERNAL_ERROR)
301 t_fetch = time.perf_counter() - t0_fetch
302
303 apply_result = apply_mpack(root, {
304 "commits": fetch_result["commits"],
305 "snapshots": fetch_result["snapshots"],
306 "blobs": fetch_result.get("blobs") or [],
307 })
308 commits_received = apply_result["commits_written"]
309 blobs_written = apply_result["blobs_written"]
310
311 if not (apply_result.get("failed_blobs") or []):
312 set_remote_head(remote, target_branch, remote_commit_id, root)
313
314 print(
315 f"[mpack] fetch/mpack: {t_fetch:.2f}s "
316 f"blobs: {fetch_result['blobs_received']} "
317 f"commits: {commits_received}",
318 file=sys.stderr,
319 )
320
321 if not json_out:
322 print(
323 f"✅ Fetched {commits_received} commit(s), {blobs_written} new blob(s) "
324 f"from {sanitize_display(remote)}/{sanitize_display(target_branch)} "
325 f"({remote_commit_id})"
326 )
327
328 if no_merge:
329 if json_out:
330 print(json.dumps(_PullJson(
331 **make_envelope(elapsed),
332 status="fetched",
333 remote=remote,
334 branch=target_branch,
335 local_branch=current_branch,
336 commits_received=commits_received,
337 blobs_written=blobs_written,
338 head=remote_commit_id,
339 conflict_paths=[],
340 dry_run=dry_run,
341 )))
342 return
343
344 # ── Phase 3: merge ────────────────────────────────────────────────────────
345 ours_commit_id = get_head_commit_id(root, current_branch)
346 theirs_commit_id = remote_commit_id
347
348 if ours_commit_id is None:
349 # No local commits yet — bootstrap: advance HEAD to the remote commit.
350 # Apply the manifest BEFORE writing the branch ref so a crash between
351 # the two leaves the ref consistent with the working tree.
352 if not dry_run:
353 theirs_commit = read_commit(root, theirs_commit_id)
354 if not theirs_commit:
355 print(
356 f"❌ Pull aborted: commit {theirs_commit_id} was fetched but "
357 "is not readable from the local store (corrupt or hash mismatch). "
358 "Run `muse verify-pack` to audit the store.",
359 file=sys.stderr,
360 )
361 raise SystemExit(ExitCode.INTERNAL_ERROR)
362 snap = read_snapshot(root, theirs_commit.snapshot_id)
363 if snap is None:
364 print(
365 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
366 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
367 "Run `muse verify-pack` to audit the store.",
368 file=sys.stderr,
369 )
370 raise SystemExit(ExitCode.INTERNAL_ERROR)
371
372 apply_manifest(root, {}, snap.manifest)
373 try:
374 write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id)
375 except RefConflictError as exc:
376 print(f"❌ {exc}", file=sys.stderr)
377 raise SystemExit(ExitCode.USER_ERROR)
378 if json_out:
379 print(json.dumps(_PullJson(
380 **make_envelope(elapsed),
381 status="fast_forward",
382 remote=remote,
383 branch=target_branch,
384 local_branch=current_branch,
385 commits_received=commits_received,
386 blobs_written=blobs_written,
387 head=None if dry_run else theirs_commit_id,
388 conflict_paths=[],
389 dry_run=dry_run,
390 )))
391 else:
392 suffix = " (dry run)" if dry_run else ""
393 print(
394 f"✅ Initialised {sanitize_display(current_branch)} "
395 f"at {theirs_commit_id}{suffix}"
396 )
397 return
398
399 if ours_commit_id == theirs_commit_id:
400 if json_out:
401 print(json.dumps(_PullJson(
402 **make_envelope(elapsed),
403 status="up_to_date",
404 remote=remote,
405 branch=target_branch,
406 local_branch=current_branch,
407 commits_received=commits_received,
408 blobs_written=blobs_written,
409 head=ours_commit_id,
410 conflict_paths=[],
411 dry_run=dry_run,
412 )))
413 else:
414 print("Already up to date.", file=sys.stderr)
415 return
416
417 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
418
419 if base_commit_id == theirs_commit_id:
420 # Local is already ahead of remote — nothing to pull.
421 if json_out:
422 print(json.dumps(_PullJson(
423 **make_envelope(elapsed),
424 status="up_to_date",
425 remote=remote,
426 branch=target_branch,
427 local_branch=current_branch,
428 commits_received=commits_received,
429 blobs_written=blobs_written,
430 head=ours_commit_id,
431 conflict_paths=[],
432 dry_run=dry_run,
433 )))
434 else:
435 print("Already up to date.", file=sys.stderr)
436 return
437
438 # Fast-forward: remote is a direct descendant of local HEAD.
439 if base_commit_id == ours_commit_id:
440 if not dry_run:
441 theirs_commit = read_commit(root, theirs_commit_id)
442 if not theirs_commit:
443 print(
444 f"❌ Pull aborted: commit {theirs_commit_id} was fetched but "
445 "is not readable from the local store (corrupt or hash mismatch). "
446 "Run `muse verify-pack` to audit the store.",
447 file=sys.stderr,
448 )
449 raise SystemExit(ExitCode.INTERNAL_ERROR)
450 snap = read_snapshot(root, theirs_commit.snapshot_id)
451 if snap is None:
452 print(
453 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
454 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
455 "Run `muse verify-pack` to audit the store.",
456 file=sys.stderr,
457 )
458 raise SystemExit(ExitCode.INTERNAL_ERROR)
459 # Apply manifest BEFORE advancing the branch pointer so a
460 # crash between the two leaves the ref consistent with the tree.
461
462 ours_ff_manifest = get_commit_snapshot_manifest(root, ours_commit_id) or {}
463 apply_manifest(root, ours_ff_manifest, snap.manifest)
464 try:
465 write_branch_ref(root, current_branch, theirs_commit_id, expected_id=ours_commit_id)
466 except RefConflictError as exc:
467 print(f"❌ {exc}", file=sys.stderr)
468 raise SystemExit(ExitCode.USER_ERROR)
469 if json_out:
470 print(json.dumps(_PullJson(
471 **make_envelope(elapsed),
472 status="fast_forward",
473 remote=remote,
474 branch=target_branch,
475 local_branch=current_branch,
476 commits_received=commits_received,
477 blobs_written=blobs_written,
478 head=None if dry_run else theirs_commit_id,
479 conflict_paths=[],
480 dry_run=dry_run,
481 )))
482 else:
483 suffix = " (dry run)" if dry_run else ""
484 print(
485 f"Fast-forward {sanitize_display(current_branch)} to "
486 f"{theirs_commit_id} "
487 f"({sanitize_display(remote)}/{sanitize_display(target_branch)}){suffix}"
488 )
489 return
490
491 # Branches have diverged — three-way merge required.
492 if ff_only:
493 if json_out:
494 print(json.dumps({
495 "error": "ff_only_refused",
496 "remote": remote,
497 "branch": target_branch,
498 "local_branch": current_branch,
499 "message": f"{remote}/{target_branch} has diverged from {current_branch} — fast-forward not possible",
500 "hint": "remove --ff-only to allow a merge commit",
501 }))
502 print(
503 f"❌ Pull aborted: {sanitize_display(remote)}/{sanitize_display(target_branch)} "
504 f"has diverged from {sanitize_display(current_branch)}.\n"
505 f" Fast-forward not possible. Remove --ff-only to allow a merge commit.",
506 file=sys.stderr,
507 )
508 raise SystemExit(ExitCode.USER_ERROR)
509
510 domain = read_domain(root)
511 plugin = resolve_plugin(root)
512
513 ours_manifest = get_head_snapshot_manifest(root, current_branch) or {}
514 theirs_commit = read_commit(root, theirs_commit_id)
515 if not theirs_commit:
516 print(
517 f"❌ Pull aborted: commit {theirs_commit_id} is not readable from "
518 "the local store (corrupt or hash mismatch). "
519 "Run `muse verify-pack` to audit the store.",
520 file=sys.stderr,
521 )
522 raise SystemExit(ExitCode.INTERNAL_ERROR)
523 theirs_snap = read_snapshot(root, theirs_commit.snapshot_id)
524 if theirs_snap is None:
525 print(
526 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id} "
527 f"referenced by commit {theirs_commit_id} is missing or corrupt. "
528 "A merge with a missing remote snapshot would treat all remote files "
529 "as deleted. Run `muse verify-pack` to audit the store.",
530 file=sys.stderr,
531 )
532 raise SystemExit(ExitCode.INTERNAL_ERROR)
533 theirs_manifest = dict(theirs_snap.manifest)
534
535 base_manifest: Manifest = {}
536 if base_commit_id:
537 base_commit = read_commit(root, base_commit_id)
538 if base_commit:
539 base_snap = read_snapshot(root, base_commit.snapshot_id)
540 if base_snap:
541 base_manifest = dict(base_snap.manifest)
542
543 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
544 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest))
545 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain, directories=directories_from_manifest(theirs_manifest))
546
547 merge_debug_log("pull.merge.enter", {
548 "caller": "muse_pull",
549 "current_branch": current_branch,
550 "remote": remote,
551 "remote_branch": target_branch,
552 "base_commit_id": base_commit_id,
553 "ours_commit_id": ours_commit_id,
554 "theirs_commit_id": theirs_commit_id,
555 "base_manifest": merge_debug_manifest_summary(base_manifest),
556 "ours_manifest": merge_debug_manifest_summary(ours_manifest),
557 "theirs_manifest": merge_debug_manifest_summary(theirs_manifest),
558 })
559
560 if isinstance(plugin, AddressedMergePlugin):
561 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
562 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
563 merge_debug_log("pull.merge.ops", {
564 "caller": "muse_pull",
565 "ours_ops": ours_delta["ops"],
566 "theirs_ops": theirs_delta["ops"],
567 })
568 result = plugin.merge_ops(
569 base_snap_obj,
570 ours_snap_obj,
571 theirs_snap_obj,
572 ours_delta["ops"],
573 theirs_delta["ops"],
574 repo_root=root,
575 )
576 else:
577 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
578
579 merge_debug_log("pull.merge.result", {
580 "caller": "muse_pull",
581 "is_clean": result.is_clean,
582 "conflicts": result.conflicts,
583 "applied_strategies": result.applied_strategies,
584 "merged_file_count": len(result.merged["files"]),
585 })
586
587 if not json_out and result.applied_strategies:
588 for p, strategy in sorted(result.applied_strategies.items()):
589 if strategy != "manual":
590 print(f" ✔ [{strategy}] {p}", file=sys.stderr)
591
592 if not result.is_clean:
593 if not dry_run:
594 write_merge_state(
595 root,
596 base_commit=base_commit_id or "",
597 ours_commit=ours_commit_id,
598 theirs_commit=theirs_commit_id,
599 conflict_paths=result.conflicts,
600 other_branch=f"{remote}/{target_branch}",
601 )
602 conflict_paths = sorted(result.conflicts)
603 if json_out:
604 print(json.dumps(_PullJson(
605 **make_envelope(elapsed, exit_code=int(ExitCode.USER_ERROR)),
606 status="conflict",
607 remote=remote,
608 branch=target_branch,
609 local_branch=current_branch,
610 commits_received=commits_received,
611 blobs_written=blobs_written,
612 head=None,
613 conflict_paths=conflict_paths,
614 dry_run=dry_run,
615 )))
616 else:
617 suffix = " (dry run — no state written)" if dry_run else ""
618 print(f"❌ Merge conflict in {len(conflict_paths)} file(s){suffix}:", file=sys.stderr)
619 for p in conflict_paths:
620 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
621 if not dry_run:
622 print('\nFix conflicts and run "muse commit" to complete the merge.', file=sys.stderr)
623 raise SystemExit(ExitCode.USER_ERROR)
624
625 merged_manifest = result.merged["files"]
626
627 if dry_run:
628 if json_out:
629 print(json.dumps(_PullJson(
630 **make_envelope(elapsed),
631 status="dry_run",
632 remote=remote,
633 branch=target_branch,
634 local_branch=current_branch,
635 commits_received=commits_received,
636 blobs_written=blobs_written,
637 head=None,
638 conflict_paths=[],
639 dry_run=True,
640 )))
641 else:
642 print(
643 f"Would merge {sanitize_display(remote)}/{sanitize_display(target_branch)} "
644 f"into {sanitize_display(current_branch)} (dry run)"
645 )
646 return
647
648
649 apply_manifest(root, {**ours_manifest, **theirs_manifest}, merged_manifest)
650
651 merged_dirs = directories_from_manifest(merged_manifest)
652 snapshot_id = hash_snapshot(merged_manifest, merged_dirs)
653 committed_at = datetime.datetime.now(datetime.timezone.utc)
654 merge_message = (
655 message
656 or f"Merge {remote}/{target_branch} into {current_branch}"
657 )
658 commit_id = hash_commit(
659 parent_ids=[ours_commit_id, theirs_commit_id],
660 snapshot_id=snapshot_id,
661 message=merge_message,
662 committed_at_iso=committed_at.isoformat(),
663 )
664 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
665 write_commit(
666 root,
667 CommitRecord(
668 commit_id=commit_id,
669 branch=current_branch,
670 snapshot_id=snapshot_id,
671 message=merge_message,
672 committed_at=committed_at,
673 parent_commit_id=ours_commit_id,
674 parent2_commit_id=theirs_commit_id,
675 ),
676 )
677 try:
678 write_branch_ref(root, current_branch, commit_id, expected_id=ours_commit_id)
679 except RefConflictError as exc:
680 print(f"❌ {exc}", file=sys.stderr)
681 raise SystemExit(ExitCode.USER_ERROR)
682
683 if json_out:
684 print(json.dumps(_PullJson(
685 **make_envelope(elapsed),
686 status="merged",
687 remote=remote,
688 branch=target_branch,
689 local_branch=current_branch,
690 commits_received=commits_received,
691 blobs_written=blobs_written,
692 head=commit_id,
693 conflict_paths=[],
694 dry_run=False,
695 )))
696 else:
697 print(
698 f"✅ Merged {sanitize_display(remote)}/{sanitize_display(target_branch)} "
699 f"into {sanitize_display(current_branch)} ({commit_id})"
700 )
File History 1 commit
sha256:39065bc65b1a541916c4ea32ccd53eac38bb93015db2be2e342326064e86c44f fix: convergent-edit phantom conflicts in ops_commute + mer… Sonnet 4.6 minor 9 days ago