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