pull.py file-level

at sha256:6 · 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 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 objects 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 "objects_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 from __future__ import annotations
40
41 import argparse
42 import datetime
43 import json
44 import logging
45 import pathlib
46 import sys
47 from typing import TypedDict
48
49 from muse.cli.config import get_signing_identity, get_remote, get_remote_head, get_upstream, set_remote_head
50 from muse.core.errors import ExitCode
51 from muse.core.merge_engine import find_merge_base, write_merge_state
52 from muse.core.object_store import has_object, write_object
53 from muse.core._types import Manifest
54 from muse.core.pack import apply_pack
55 from muse.core.repo import read_repo_id, require_repo
56 from muse.core.snapshot import compute_commit_id, compute_snapshot_id, directories_from_manifest
57 from muse.core.store import (
58 CommitRecord,
59 SnapshotRecord,
60 get_all_commits,
61 get_head_commit_id,
62 get_head_snapshot_manifest,
63 read_commit,
64 read_current_branch,
65 read_snapshot,
66 write_branch_ref,
67 write_commit,
68 write_snapshot,
69 )
70 from muse.core.transport import (
71 HttpTransport,
72 LocalFileTransport,
73 TransportError,
74 make_transport,
75 negotiate_have,
76 )
77 from muse.core.workdir import apply_manifest
78 from muse.domain import SnapshotManifest, StructuredMergePlugin
79 from muse.plugins.registry import read_domain, resolve_plugin
80 from muse.core.validation import sanitize_display
81 from muse.core._types import Manifest
82
83 logger = logging.getLogger(__name__)
84
85
86 class _PullJson(TypedDict):
87 """Stable JSON schema emitted by ``muse pull --json``."""
88
89 status: str # up_to_date | fast_forward | merged | conflict | fetched | dry_run
90 remote: str
91 branch: str # remote branch pulled from
92 local_branch: str
93 commits_received: int
94 objects_written: int
95 head: str | None # new local HEAD after the pull, null on conflict/dry_run
96 conflict_paths: list[str]
97 dry_run: bool
98
99
100 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
101 """Register the ``muse pull`` subcommand and all its flags."""
102 parser = subparsers.add_parser(
103 "pull",
104 help="Fetch from a remote and merge into the current branch.",
105 description=__doc__,
106 formatter_class=argparse.RawDescriptionHelpFormatter,
107 )
108 parser.add_argument(
109 "remote", nargs="?", default="origin",
110 help="Remote name to pull from (default: origin).",
111 )
112 parser.add_argument(
113 "branch_pos", nargs="?", default=None, metavar="BRANCH",
114 help="Remote branch to pull (default: tracked branch or current branch). Same as --branch.",
115 )
116 parser.add_argument(
117 "--branch", "-b", default=None, dest="branch_flag",
118 help="Remote branch to pull (default: tracked branch or current branch).",
119 )
120 parser.add_argument(
121 "--no-merge", action="store_true", dest="no_merge",
122 help="Only fetch; do not merge into the current branch.",
123 )
124 parser.add_argument(
125 "--ff-only", action="store_true", dest="ff_only",
126 help="Refuse to pull if a fast-forward merge is not possible.",
127 )
128 parser.add_argument(
129 "-n", "--dry-run", action="store_true",
130 help="Preview what would change without modifying the working tree or writing commits.",
131 )
132 parser.add_argument(
133 "-m", "--message", default=None,
134 help="Override the merge commit message.",
135 )
136 parser.add_argument(
137 "--format", "-f", default="text", dest="fmt",
138 help="Output format: text or json.",
139 )
140 parser.add_argument(
141 "--json", action="store_const", const="json", dest="fmt",
142 help="Shorthand for --format json.",
143 )
144 parser.set_defaults(func=run)
145
146
147 def run(args: argparse.Namespace) -> None:
148 """Fetch from a remote and merge into the current branch.
149
150 All progress and error messages go to **stderr**. ``--format json``
151 (or ``--json``) emits a single JSON object on stdout for agent pipelines.
152
153 ``--dry-run`` contacts the remote to discover the current HEAD but does
154 not fetch objects, apply manifests, or write any commits. The JSON output
155 shows exactly what *would* happen (``status``, ``commits_received``
156 estimate, ``conflict_paths`` if a three-way merge would be needed).
157
158 ``--ff-only`` exits with code 1 if the remote HEAD is not a descendant of
159 the local HEAD, preventing silent three-way merge commits.
160
161 JSON schema::
162
163 {
164 "status": "up_to_date | fast_forward | merged | conflict | fetched | dry_run",
165 "remote": "<remote_name>",
166 "branch": "<remote_branch>",
167 "local_branch": "<local_branch>",
168 "commits_received": <N>,
169 "objects_written": <N>,
170 "head": "<sha256> | null",
171 "conflict_paths": ["<path>", ...],
172 "dry_run": false
173 }
174
175 Exit codes::
176
177 0 β€” success
178 1 β€” configuration, network, or ff-only refusal error
179 2 β€” merge conflict (resolve, then ``muse commit``)
180 """
181 remote: str = args.remote
182 branch: str | None = (
183 getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None)
184 )
185 no_merge: bool = args.no_merge
186 ff_only: bool = getattr(args, "ff_only", False)
187 dry_run: bool = getattr(args, "dry_run", False)
188 message: str | None = args.message
189 fmt: str = getattr(args, "fmt", "text")
190
191 if fmt not in ("text", "json"):
192 print(
193 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
194 file=sys.stderr,
195 )
196 raise SystemExit(ExitCode.USER_ERROR)
197
198 root = require_repo()
199
200 url = get_remote(remote, root)
201 if url is None:
202 print(
203 f"❌ Remote '{sanitize_display(remote)}' is not configured.\n"
204 f" Add it with: muse remote add {sanitize_display(remote)} <url>",
205 file=sys.stderr,
206 )
207 raise SystemExit(ExitCode.USER_ERROR)
208
209 signing = get_signing_identity(root, remote_url=url)
210 current_branch = read_current_branch(root)
211 target_branch = branch or get_upstream(current_branch, root) or current_branch
212
213 transport: HttpTransport | LocalFileTransport = make_transport(url)
214
215 # ── Phase 0: discover remote HEAD ────────────────────────────────────────
216 try:
217 info = transport.fetch_remote_info(url, signing)
218 except TransportError as exc:
219 print(
220 f"❌ Cannot reach remote '{sanitize_display(remote)}': "
221 f"{sanitize_display(str(exc))}",
222 file=sys.stderr,
223 )
224 raise SystemExit(ExitCode.INTERNAL_ERROR)
225
226 remote_commit_id = info["branch_heads"].get(target_branch)
227 if remote_commit_id is None:
228 print(
229 f"❌ Branch '{sanitize_display(target_branch)}' does not exist "
230 f"on remote '{sanitize_display(remote)}'.",
231 file=sys.stderr,
232 )
233 raise SystemExit(ExitCode.USER_ERROR)
234
235 # ── Phase 1: fetch ────────────────────────────────────────────────────────
236 commits_received: int = 0
237 objects_written: int = 0
238
239 already_known = get_remote_head(remote, target_branch, root)
240 if already_known == remote_commit_id:
241 # We have everything for this commit β€” skip network fetch entirely.
242 if fmt == "text":
243 print(
244 f"βœ… Fetched 0 commit(s), 0 new object(s) from "
245 f"{sanitize_display(remote)}/{sanitize_display(target_branch)} "
246 f"({remote_commit_id[:8]})"
247 )
248 else:
249 print(
250 f"Fetching {sanitize_display(remote)}/{sanitize_display(target_branch)} …",
251 file=sys.stderr,
252 )
253
254 if dry_run:
255 # In dry-run mode we don't fetch objects β€” just report what would arrive.
256 # We estimate commits_received from the remote pack (free from /refs response).
257 if fmt == "json":
258 print(json.dumps(_PullJson(
259 status="dry_run",
260 remote=remote,
261 branch=target_branch,
262 local_branch=current_branch,
263 commits_received=0,
264 objects_written=0,
265 head=None,
266 conflict_paths=[],
267 dry_run=True,
268 )))
269 else:
270 print(
271 f"Would fetch {sanitize_display(remote)}/{sanitize_display(target_branch)} "
272 f"β†’ {remote_commit_id[:8]} (dry run)"
273 )
274 return
275
276 # Optimised have-list: try just the local HEAD first (common case of
277 # being 1–few commits behind), then fall back to full history.
278 local_head = get_head_commit_id(root, current_branch)
279 have_for_fetch: list[str]
280 try:
281 quick_have = [local_head] if local_head else []
282 resp = transport.negotiate(url, signing, want=[remote_commit_id], have=quick_have)
283 if resp["ready"]:
284 have_for_fetch = resp["ack"] or quick_have
285 else:
286 all_local = [c.commit_id for c in get_all_commits(root)]
287 have_for_fetch = negotiate_have(
288 transport, url, signing, [remote_commit_id], all_local
289 )
290 except TransportError:
291 logger.debug("negotiate not supported, falling back to full have list")
292 have_for_fetch = [c.commit_id for c in get_all_commits(root)]
293
294 try:
295 bundle = transport.fetch_pack(
296 url, signing, want=[remote_commit_id], have=have_for_fetch
297 )
298 except TransportError as exc:
299 print(
300 f"❌ Fetch failed: {sanitize_display(str(exc))}",
301 file=sys.stderr,
302 )
303 raise SystemExit(ExitCode.INTERNAL_ERROR)
304
305 apply_result = apply_pack(root, bundle)
306 commits_received = apply_result["commits_written"]
307 objects_written = apply_result["objects_written"]
308 set_remote_head(remote, target_branch, remote_commit_id, root)
309
310 if fmt == "text":
311 print(
312 f"βœ… Fetched {commits_received} commit(s), {objects_written} new object(s) "
313 f"from {sanitize_display(remote)}/{sanitize_display(target_branch)} "
314 f"({remote_commit_id[:8]})"
315 )
316
317 # ── Phase 2: ensure objects for the target snapshot ─────────────────────────
318 # fetch_pack returns only VCS metadata (commits + the tip snapshot with its
319 # manifest). Before we can apply_manifest to restore the working tree, we must
320 # ensure all objects listed in the target snapshot are present locally.
321 # This runs even when "already_known == remote_commit_id" because the local
322 # object store may be incomplete (e.g. after a failed previous clone/pull).
323 def _ensure_snapshot_objects(snap_manifest: Manifest) -> int:
324 """Fetch any missing objects for *snap_manifest* and return count written."""
325 missing_oids = [oid for oid in snap_manifest.values() if not has_object(root, oid)]
326 if not missing_oids:
327 return 0
328 logger.debug("pull: fetching %d missing object(s) via /fetch/objects", len(missing_oids))
329 try:
330 fetched_objs = transport.fetch_objects(url, signing, missing_oids)
331 except TransportError as exc:
332 print(
333 f"❌ Fetch objects failed: {sanitize_display(str(exc))}",
334 file=sys.stderr,
335 )
336 raise SystemExit(ExitCode.INTERNAL_ERROR)
337 written = 0
338 for obj in fetched_objs:
339 oid = obj.get("object_id", "")
340 raw = obj.get("content", b"")
341 if oid and isinstance(raw, bytes) and raw:
342 if write_object(root, oid, raw):
343 written += 1
344 return written
345
346 if no_merge:
347 if fmt == "json":
348 print(json.dumps(_PullJson(
349 status="fetched",
350 remote=remote,
351 branch=target_branch,
352 local_branch=current_branch,
353 commits_received=commits_received,
354 objects_written=objects_written,
355 head=remote_commit_id,
356 conflict_paths=[],
357 dry_run=dry_run,
358 )))
359 return
360
361 # ── Phase 3: merge ────────────────────────────────────────────────────────
362 repo_id = read_repo_id(root)
363 ours_commit_id = get_head_commit_id(root, current_branch)
364 theirs_commit_id = remote_commit_id
365
366 if ours_commit_id is None:
367 # No local commits yet β€” bootstrap: advance HEAD to the remote commit.
368 # Apply the manifest BEFORE writing the branch ref so a crash between
369 # the two leaves the ref consistent with the working tree.
370 if not dry_run:
371 theirs_commit = read_commit(root, theirs_commit_id)
372 if not theirs_commit:
373 print(
374 f"❌ Pull aborted: commit {theirs_commit_id[:8]} was fetched but "
375 "is not readable from the local store (corrupt or hash mismatch). "
376 "Run `muse verify-pack` to audit the store.",
377 file=sys.stderr,
378 )
379 raise SystemExit(ExitCode.INTERNAL_ERROR)
380 snap = read_snapshot(root, theirs_commit.snapshot_id)
381 if snap is None:
382 print(
383 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id[:8]} "
384 f"referenced by commit {theirs_commit_id[:8]} is missing or corrupt. "
385 "Run `muse verify-pack` to audit the store.",
386 file=sys.stderr,
387 )
388 raise SystemExit(ExitCode.INTERNAL_ERROR)
389 objects_written += _ensure_snapshot_objects(snap.manifest)
390 apply_manifest(root, snap.manifest)
391 write_branch_ref(root, current_branch, theirs_commit_id)
392 if fmt == "json":
393 print(json.dumps(_PullJson(
394 status="fast_forward",
395 remote=remote,
396 branch=target_branch,
397 local_branch=current_branch,
398 commits_received=commits_received,
399 objects_written=objects_written,
400 head=None if dry_run else theirs_commit_id,
401 conflict_paths=[],
402 dry_run=dry_run,
403 )))
404 else:
405 suffix = " (dry run)" if dry_run else ""
406 print(
407 f"βœ… Initialised {sanitize_display(current_branch)} "
408 f"at {theirs_commit_id[:8]}{suffix}"
409 )
410 return
411
412 if ours_commit_id == theirs_commit_id:
413 if fmt == "json":
414 print(json.dumps(_PullJson(
415 status="up_to_date",
416 remote=remote,
417 branch=target_branch,
418 local_branch=current_branch,
419 commits_received=commits_received,
420 objects_written=objects_written,
421 head=ours_commit_id,
422 conflict_paths=[],
423 dry_run=dry_run,
424 )))
425 else:
426 print("Already up to date.", file=sys.stderr)
427 return
428
429 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
430
431 if base_commit_id == theirs_commit_id:
432 # Local is already ahead of remote β€” nothing to pull.
433 if fmt == "json":
434 print(json.dumps(_PullJson(
435 status="up_to_date",
436 remote=remote,
437 branch=target_branch,
438 local_branch=current_branch,
439 commits_received=commits_received,
440 objects_written=objects_written,
441 head=ours_commit_id,
442 conflict_paths=[],
443 dry_run=dry_run,
444 )))
445 else:
446 print("Already up to date.", file=sys.stderr)
447 return
448
449 # Fast-forward: remote is a direct descendant of local HEAD.
450 if base_commit_id == ours_commit_id:
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[:8]} 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[:8]} "
465 f"referenced by commit {theirs_commit_id[:8]} 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 # Apply manifest BEFORE advancing the branch pointer so a
471 # crash between the two leaves the ref consistent with the tree.
472 objects_written += _ensure_snapshot_objects(snap.manifest)
473 apply_manifest(root, snap.manifest)
474 write_branch_ref(root, current_branch, theirs_commit_id)
475 if fmt == "json":
476 print(json.dumps(_PullJson(
477 status="fast_forward",
478 remote=remote,
479 branch=target_branch,
480 local_branch=current_branch,
481 commits_received=commits_received,
482 objects_written=objects_written,
483 head=None if dry_run else theirs_commit_id,
484 conflict_paths=[],
485 dry_run=dry_run,
486 )))
487 else:
488 suffix = " (dry run)" if dry_run else ""
489 print(
490 f"Fast-forward {sanitize_display(current_branch)} to "
491 f"{theirs_commit_id[:8]} "
492 f"({sanitize_display(remote)}/{sanitize_display(target_branch)}){suffix}"
493 )
494 return
495
496 # Branches have diverged β€” three-way merge required.
497 if ff_only:
498 print(
499 f"❌ Pull aborted: {sanitize_display(remote)}/{sanitize_display(target_branch)} "
500 f"has diverged from {sanitize_display(current_branch)}.\n"
501 f" Fast-forward not possible. Remove --ff-only to allow a merge commit.",
502 file=sys.stderr,
503 )
504 raise SystemExit(ExitCode.USER_ERROR)
505
506 domain = read_domain(root)
507 plugin = resolve_plugin(root)
508
509 ours_manifest = get_head_snapshot_manifest(root, repo_id, current_branch) or {}
510 theirs_commit = read_commit(root, theirs_commit_id)
511 if not theirs_commit:
512 print(
513 f"❌ Pull aborted: commit {theirs_commit_id[:8]} is not readable from "
514 "the local store (corrupt or hash mismatch). "
515 "Run `muse verify-pack` to audit the store.",
516 file=sys.stderr,
517 )
518 raise SystemExit(ExitCode.INTERNAL_ERROR)
519 theirs_snap = read_snapshot(root, theirs_commit.snapshot_id)
520 if theirs_snap is None:
521 print(
522 f"❌ Pull aborted: snapshot {theirs_commit.snapshot_id[:8]} "
523 f"referenced by commit {theirs_commit_id[:8]} is missing or corrupt. "
524 "A merge with a missing remote snapshot would treat all remote files "
525 "as deleted. Run `muse verify-pack` to audit the store.",
526 file=sys.stderr,
527 )
528 raise SystemExit(ExitCode.INTERNAL_ERROR)
529 theirs_manifest = dict(theirs_snap.manifest)
530
531 base_manifest: Manifest = {}
532 if base_commit_id:
533 base_commit = read_commit(root, base_commit_id)
534 if base_commit:
535 base_snap = read_snapshot(root, base_commit.snapshot_id)
536 if base_snap:
537 base_manifest = dict(base_snap.manifest)
538
539 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
540 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest))
541 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain, directories=directories_from_manifest(theirs_manifest))
542
543 if isinstance(plugin, StructuredMergePlugin):
544 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
545 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
546 result = plugin.merge_ops(
547 base_snap_obj,
548 ours_snap_obj,
549 theirs_snap_obj,
550 ours_delta["ops"],
551 theirs_delta["ops"],
552 repo_root=root,
553 )
554 else:
555 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
556
557 if fmt == "text" and result.applied_strategies:
558 for p, strategy in sorted(result.applied_strategies.items()):
559 if strategy != "manual":
560 print(f" βœ” [{strategy}] {p}", file=sys.stderr)
561
562 if not result.is_clean:
563 if not dry_run:
564 write_merge_state(
565 root,
566 base_commit=base_commit_id or "",
567 ours_commit=ours_commit_id,
568 theirs_commit=theirs_commit_id,
569 conflict_paths=result.conflicts,
570 other_branch=f"{remote}/{target_branch}",
571 )
572 conflict_paths = sorted(result.conflicts)
573 if fmt == "json":
574 print(json.dumps(_PullJson(
575 status="conflict",
576 remote=remote,
577 branch=target_branch,
578 local_branch=current_branch,
579 commits_received=commits_received,
580 objects_written=objects_written,
581 head=None,
582 conflict_paths=conflict_paths,
583 dry_run=dry_run,
584 )))
585 else:
586 suffix = " (dry run β€” no state written)" if dry_run else ""
587 print(f"❌ Merge conflict in {len(conflict_paths)} file(s){suffix}:", file=sys.stderr)
588 for p in conflict_paths:
589 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
590 if not dry_run:
591 print('\nFix conflicts and run "muse commit" to complete the merge.', file=sys.stderr)
592 raise SystemExit(ExitCode.USER_ERROR)
593
594 merged_manifest = result.merged["files"]
595
596 if dry_run:
597 if fmt == "json":
598 print(json.dumps(_PullJson(
599 status="dry_run",
600 remote=remote,
601 branch=target_branch,
602 local_branch=current_branch,
603 commits_received=commits_received,
604 objects_written=objects_written,
605 head=None,
606 conflict_paths=[],
607 dry_run=True,
608 )))
609 else:
610 print(
611 f"Would merge {sanitize_display(remote)}/{sanitize_display(target_branch)} "
612 f"into {sanitize_display(current_branch)} (dry run)"
613 )
614 return
615
616 objects_written += _ensure_snapshot_objects(merged_manifest)
617 apply_manifest(root, merged_manifest)
618
619 merged_dirs = directories_from_manifest(merged_manifest)
620 snapshot_id = compute_snapshot_id(merged_manifest, merged_dirs)
621 committed_at = datetime.datetime.now(datetime.timezone.utc)
622 merge_message = (
623 message
624 or f"Merge {remote}/{target_branch} into {current_branch}"
625 )
626 commit_id = compute_commit_id(
627 parent_ids=[ours_commit_id, theirs_commit_id],
628 snapshot_id=snapshot_id,
629 message=merge_message,
630 committed_at_iso=committed_at.isoformat(),
631 )
632 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest, directories=merged_dirs))
633 write_commit(
634 root,
635 CommitRecord(
636 commit_id=commit_id,
637 repo_id=repo_id,
638 branch=current_branch,
639 snapshot_id=snapshot_id,
640 message=merge_message,
641 committed_at=committed_at,
642 parent_commit_id=ours_commit_id,
643 parent2_commit_id=theirs_commit_id,
644 ),
645 )
646 write_branch_ref(root, current_branch, commit_id)
647
648 if fmt == "json":
649 print(json.dumps(_PullJson(
650 status="merged",
651 remote=remote,
652 branch=target_branch,
653 local_branch=current_branch,
654 commits_received=commits_received,
655 objects_written=objects_written,
656 head=commit_id,
657 conflict_paths=[],
658 dry_run=False,
659 )))
660 else:
661 print(
662 f"βœ… Merged {sanitize_display(remote)}/{sanitize_display(target_branch)} "
663 f"into {sanitize_display(current_branch)} ({commit_id[:8]})"
664 )