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