gabriel / muse public
rebase.py python
919 lines 32.0 KB
Raw
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 72 days ago
1 """``muse rebase`` — replay commits from one branch onto another.
2
3 Muse rebase is cherry-pick of a range: it takes the commits unique to the
4 current branch (those not reachable from the upstream) and replays them
5 one-by-one on top of the upstream. Because commits are content-addressed,
6 each replayed commit gets a new ID — the originals are untouched in the store.
7
8 Usage::
9
10 muse rebase <upstream> # replay HEAD's unique commits onto upstream
11 muse rebase --onto <newbase> <upstream> # replay onto a different base
12 muse rebase --squash [<upstream>] # collapse all commits into one
13 muse rebase --squash -m "msg" [<upstream>] # squash with explicit commit message
14 muse rebase --dry-run <upstream> # preview which commits would be replayed
15 muse rebase --status # show progress of an in-progress rebase
16 muse rebase --abort # restore original HEAD
17 muse rebase --continue # resume after resolving a conflict
18 muse rebase --max-commits N <upstream> # cap the number of commits replayed
19
20 All subcommands accept ``--json`` for machine-readable output::
21
22 muse rebase --json main
23 muse rebase --abort --json
24 muse rebase --status --json
25
26 All JSON outputs include standard envelope fields (``duration_ms``,
27 ``exit_code``, ``muse_version``, ``timestamp``, ``schema``, ``warnings``).
28 Result payloads also include ``replayed_commit_ids``
29 (list of sha256:-prefixed commit IDs produced during replay).
30
31 Exit codes::
32
33 0 — success (completed, aborted, dry-run, status, up_to_date)
34 1 — conflict encountered, bad arguments, or user error
35 3 — internal error
36 """
37
38 import argparse
39 import datetime
40 import json
41 import logging
42 import pathlib
43 import sys
44 import time
45 from typing import TypedDict
46
47 from muse.core.envelope import EnvelopeJson, make_envelope
48 from muse.core.errors import ExitCode
49 from muse.core.merge_engine import find_merge_base, write_merge_state
50 from muse.core.rebase import (
51 RebaseState,
52 _write_branch_ref,
53 clear_rebase_state,
54 collect_commits_to_replay,
55 get_rebase_progress,
56 load_rebase_state,
57 replay_one,
58 save_rebase_state,
59 )
60 from muse.core.reflog import append_reflog
61 from muse.core.repo import require_repo
62 from muse.core.ids import hash_commit, hash_snapshot
63 from muse.core.snapshot import directories_from_manifest
64 from muse.core.refs import read_ref
65 from muse.core.types import Manifest
66 from muse.core.refs import (
67 RefConflictError,
68 get_head_commit_id,
69 read_current_branch,
70 )
71 from muse.core.commits import (
72 CommitRecord,
73 read_commit,
74 resolve_commit_ref,
75 write_commit,
76 )
77 from muse.core.snapshots import (
78 SnapshotRecord,
79 read_snapshot,
80 write_snapshot,
81 )
82 from muse.core.validation import sanitize_display, validate_branch_name
83 from muse.core.types import long_id, short_id, split_id
84 from muse.core.paths import ref_path as _ref_path
85 from muse.core.workdir import apply_manifest
86 from muse.core.timing import start_timer
87 from muse.domain import MuseDomainPlugin, SnapshotManifest
88 from muse.plugins.registry import read_domain, resolve_plugin
89
90 logger = logging.getLogger(__name__)
91
92 # ---------------------------------------------------------------------------
93 # JSON wire formats
94 # ---------------------------------------------------------------------------
95
96 class _RebaseResultJson(EnvelopeJson):
97 """JSON output for a completed rebase (normal or squash).
98
99 ``replayed_commit_ids`` contains the new (rebased) commit IDs produced
100 during replay — sha256:-prefixed, in replay order. Empty for aborted,
101 conflict, and up_to_date outcomes.
102 """
103
104 status: str # "completed" | "conflict" | "aborted" | "up_to_date"
105 branch: str
106 new_head: str | None
107 onto: str
108 squash: bool
109 replayed: int
110 replayed_commit_ids: list[str]
111 conflicts: list[str]
112
113 class _RebaseStatusJson(EnvelopeJson):
114 """JSON output for ``muse rebase --status``."""
115
116 active: bool
117 original_branch: str
118 original_head: str
119 onto: str
120 total: int
121 done: int
122 remaining: int
123 squash: bool
124
125 class _RebaseDryRunCommitJson(TypedDict):
126 """One entry in the ``--dry-run`` commits list."""
127
128 commit_id: str
129 message: str
130
131 class _RebaseDryRunJson(EnvelopeJson):
132 """JSON output for ``muse rebase --dry-run``."""
133
134 branch: str
135 onto: str
136 commits: list[_RebaseDryRunCommitJson]
137 count: int
138 squash: bool
139
140 # ---------------------------------------------------------------------------
141 # Internal helpers
142 # ---------------------------------------------------------------------------
143
144 def _resolve_ref_to_id(
145 root: pathlib.Path,
146 branch: str,
147 ref: str,
148 ) -> str | None:
149 """Resolve a ref string (branch name, commit SHA, or HEAD) to a commit ID.
150
151 Branch names are validated with ``validate_branch_name`` before being used
152 as path components to prevent directory traversal attacks.
153 """
154 if ref.upper() == "HEAD":
155 return get_head_commit_id(root, branch)
156
157 # Try as a branch ref — validate before using as a path component.
158 is_valid_branch = True
159 try:
160 validate_branch_name(ref)
161 except ValueError:
162 is_valid_branch = False
163
164 if is_valid_branch:
165 ref_path = _ref_path(root, ref)
166 raw = read_ref(ref_path)
167 if raw is not None and raw.startswith("sha256:"):
168 _, hex_part = split_id(raw)
169 if len(hex_part) == 64 and all(c in "0123456789abcdef" for c in hex_part):
170 return raw
171
172 # Fall back to commit SHA prefix resolution.
173 rec = resolve_commit_ref(root, branch, ref)
174 return rec.commit_id if rec else None
175
176 def _run_replay_loop(
177 root: pathlib.Path,
178 state: RebaseState,
179 branch: str,
180 plugin: MuseDomainPlugin,
181 domain: str,
182 json_out: bool,
183 t0: float,
184 ) -> bool:
185 """Run the replay loop.
186
187 Emits progress text (or NDJSON per commit when *json_out* is True)
188 to stdout and writes ``MERGE_STATE.json`` on conflict.
189
190 Args:
191 t0: ``time.monotonic()`` value captured at the start of ``run()``
192 — used to compute ``elapsed()`` in conflict JSON.
193
194 Returns:
195 ``True`` if all commits were replayed cleanly; ``False`` on conflict.
196 """
197 current_parent = state["completed"][-1] if state["completed"] else state["onto"]
198
199 while state["remaining"]:
200 orig_commit_id = state["remaining"][0]
201 commit = read_commit(root, orig_commit_id)
202 if commit is None:
203 logger.warning("⚠️ Commit %s not found — skipping.", orig_commit_id)
204 state["remaining"].pop(0)
205 save_rebase_state(root, state)
206 continue
207
208 if not json_out:
209 total = len(state["completed"]) + len(state["remaining"])
210 done = len(state["completed"]) + 1
211 print(
212 f" [{done}/{total}] Replaying {orig_commit_id}: "
213 f"{sanitize_display(commit.message)}"
214 )
215
216 result = replay_one(
217 root, commit, current_parent, plugin, domain, branch
218 )
219
220 if isinstance(result, list):
221 # Conflict — write state and pause.
222 state["remaining"].insert(0, orig_commit_id)
223 save_rebase_state(root, state)
224
225 write_merge_state(
226 root,
227 base_commit=commit.parent_commit_id or "",
228 ours_commit=current_parent,
229 theirs_commit=orig_commit_id,
230 conflict_paths=result,
231 )
232 if json_out:
233 result_payload = _RebaseResultJson(
234 **make_envelope(elapsed, exit_code=1),
235 status="conflict",
236 branch=branch,
237 new_head=None,
238 onto=state["onto"],
239 squash=False,
240 replayed=len(state["completed"]),
241 replayed_commit_ids=list(state["completed"]),
242 conflicts=sorted(result),
243 )
244 print(json.dumps(result_payload))
245 else:
246 print(
247 f"\n❌ Rebase stopped at {orig_commit_id} due to conflict(s):",
248 file=sys.stderr,
249 )
250 for p in sorted(result):
251 print(f" CONFLICT: {p}", file=sys.stderr)
252 print(
253 "\nResolve conflicts then run:\n"
254 " muse rebase --continue to resume\n"
255 " muse rebase --abort to restore original HEAD",
256 file=sys.stderr,
257 )
258 return False
259
260 # Clean replay — advance.
261 current_parent = result.commit_id
262 state["remaining"].pop(0)
263 state["completed"].append(result.commit_id)
264 save_rebase_state(root, state)
265
266 old_id = (
267 state["completed"][-2]
268 if len(state["completed"]) >= 2
269 else state["onto"]
270 )
271 append_reflog(
272 root, branch,
273 old_id=old_id,
274 new_id=result.commit_id,
275 author="user",
276 operation=f"rebase: replayed {orig_commit_id} onto {state['onto']}",
277 )
278
279 return True
280
281 # ---------------------------------------------------------------------------
282 # Registration
283 # ---------------------------------------------------------------------------
284
285 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
286 """Register the ``muse rebase`` subcommand and all its flags."""
287 parser = subparsers.add_parser(
288 "rebase",
289 help="Replay commits from the current branch onto a new base.",
290 description=__doc__,
291 formatter_class=argparse.RawDescriptionHelpFormatter,
292 )
293 parser.add_argument(
294 "upstream",
295 nargs="?",
296 default=None,
297 metavar="UPSTREAM",
298 help="Branch or commit to rebase onto.",
299 )
300 parser.add_argument(
301 "--onto",
302 default=None,
303 metavar="NEWBASE",
304 help="Replay commits onto this base instead of upstream.",
305 )
306 parser.add_argument(
307 "--squash",
308 action="store_true",
309 help="Collapse all replayed commits into one.",
310 )
311 parser.add_argument(
312 "--message", "-m",
313 dest="squash_message",
314 default=None,
315 metavar="MSG",
316 help="Commit message for the squashed commit (only with --squash).",
317 )
318 parser.add_argument(
319 "--abort",
320 action="store_true",
321 help="Abort an in-progress rebase and restore original HEAD.",
322 )
323 parser.add_argument(
324 "--continue",
325 dest="continue_",
326 action="store_true",
327 help="Resume a paused rebase after resolving conflicts.",
328 )
329 parser.add_argument(
330 "-n", "--dry-run",
331 dest="dry_run",
332 action="store_true",
333 help="Show which commits would be replayed without actually replaying them.",
334 )
335 parser.add_argument(
336 "--status",
337 action="store_true",
338 help="Show progress of an in-progress rebase.",
339 )
340 parser.add_argument(
341 "--max-commits",
342 type=int,
343 default=10_000,
344 dest="max_commits",
345 metavar="N",
346 help="Maximum number of commits to replay (default: 10 000).",
347 )
348 parser.add_argument(
349 "--json", "-j",
350 action="store_true",
351 dest="json_out",
352 help="Emit machine-readable JSON on stdout.",
353 )
354 parser.set_defaults(func=run)
355
356 # ---------------------------------------------------------------------------
357 # Main handler
358 # ---------------------------------------------------------------------------
359
360 def run(args: argparse.Namespace) -> None:
361 """Replay commits from the current branch onto a new base.
362
363 The most common invocation replays all commits unique to the current
364 branch on top of *upstream*'s HEAD::
365
366 muse rebase main # rebase current branch onto main
367 muse rebase --json main # same, machine-readable
368
369 Use ``--onto`` when you need to replay onto a commit that is not the
370 tip of *upstream*::
371
372 muse rebase --onto newbase upstream
373
374 Use ``--squash`` to collapse all replayed commits into one::
375
376 muse rebase --squash main
377 muse rebase --squash -m "feat: combined" main
378
379 Use ``--dry-run`` to preview which commits would be replayed::
380
381 muse rebase --dry-run main
382 muse rebase --dry-run --json main
383
384 Use ``--status`` to inspect an in-progress rebase::
385
386 muse rebase --status
387 muse rebase --status --json
388
389 When a conflict is encountered, the rebase pauses. Resolve the conflict,
390 stage the resolved files, then::
391
392 muse rebase --continue
393
394 Or discard the entire rebase::
395
396 muse rebase --abort
397
398 JSON output
399 -----------
400 All outcomes emit a consistent payload when ``--json`` is supplied:
401
402 All JSON payloads include standard envelope fields (``duration_ms``,
403 ``exit_code``, ``muse_version``, ``timestamp``, ``schema``, ``warnings``).
404
405 Completed rebase::
406
407 {"status":"completed","branch":"feat/x","new_head":"sha256:<hex>",
408 "onto":"sha256:<hex>","squash":false,"replayed":3,
409 "replayed_commit_ids":["sha256:<hex>",...],
410 "conflicts":[],"duration_ms":12.3,"exit_code":0}
411
412 Conflict::
413
414 {"status":"conflict","branch":"feat/x","new_head":null,"onto":"sha256:<hex>",
415 "squash":false,"replayed":1,"replayed_commit_ids":["sha256:<hex>"],
416 "conflicts":["path/to/file"],"duration_ms":5.1,"exit_code":1}
417
418 Aborted::
419
420 {"status":"aborted","branch":"feat/x","new_head":"sha256:<hex>",
421 "onto":"sha256:<hex>","squash":false,"replayed":0,
422 "replayed_commit_ids":[],"conflicts":[],"duration_ms":2.0,"exit_code":0}
423
424 Already up to date::
425
426 {"status":"up_to_date","branch":"feat/x","new_head":"sha256:<hex>",
427 "onto":"sha256:<hex>","squash":false,"replayed":0,
428 "replayed_commit_ids":[],"conflicts":[],"duration_ms":1.5,"exit_code":0}
429
430 Dry-run::
431
432 {"branch":"feat/x","onto":"sha256:<hex>","commits":[...],"count":3,
433 "squash":false,"duration_ms":3.2,"exit_code":0}
434
435 Status::
436
437 {"active":true,"original_branch":"feat/x","original_head":"sha256:<hex>",
438 "onto":"sha256:<hex>","total":5,"done":2,"remaining":3,"squash":false,
439 "duration_ms":0.5,"exit_code":0}
440 """
441 import time as _time
442 t0 = _time.monotonic()
443 elapsed = start_timer()
444
445 upstream: str | None = args.upstream
446 onto: str | None = getattr(args, "onto", None)
447 squash: bool = args.squash
448 squash_message: str | None = args.squash_message
449 abort: bool = args.abort
450 continue_: bool = args.continue_
451 dry_run: bool = args.dry_run
452 show_status: bool = args.status
453 max_commits: int = args.max_commits
454 json_out: bool = args.json_out
455
456 root = require_repo()
457 branch = read_current_branch(root)
458
459 # --status — does not need plugin/domain
460 if show_status:
461 progress = get_rebase_progress(root)
462 if json_out:
463 status_payload = _RebaseStatusJson(
464 **make_envelope(elapsed),
465 active=progress["active"],
466 original_branch=progress["original_branch"],
467 original_head=progress["original_head"],
468 onto=progress["onto"],
469 total=progress["total"],
470 done=progress["done"],
471 remaining=progress["remaining"],
472 squash=progress["squash"],
473 )
474 print(json.dumps(status_payload))
475 else:
476 if not progress["active"]:
477 print("No rebase in progress.")
478 else:
479 print(
480 f"Rebase in progress on '{sanitize_display(progress['original_branch'])}'\n"
481 f" onto: {progress['onto']}\n"
482 f" progress: {progress['done']}/{progress['total']} commits done "
483 f"({progress['remaining']} remaining)\n"
484 f" squash: {progress['squash']}"
485 )
486 return
487
488 plugin = resolve_plugin(root)
489 domain = read_domain(root)
490 active_state = load_rebase_state(root)
491
492 # --abort
493 if abort:
494 if active_state is None:
495 print("❌ No rebase in progress.", file=sys.stderr)
496 raise SystemExit(ExitCode.USER_ERROR)
497
498 original_head = active_state["original_head"]
499 original_branch = active_state["original_branch"]
500 _write_branch_ref(root, original_branch, original_head)
501
502 orig_commit = read_commit(root, original_head)
503 if orig_commit:
504 snap = read_snapshot(root, orig_commit.snapshot_id)
505 if snap:
506 apply_manifest(root, {}, snap.manifest)
507
508 append_reflog(
509 root, original_branch,
510 old_id=active_state["completed"][-1] if active_state["completed"] else active_state["onto"],
511 new_id=original_head,
512 author="user",
513 operation="rebase: abort",
514 )
515 clear_rebase_state(root)
516
517 if json_out:
518 result_payload = _RebaseResultJson(
519 **make_envelope(elapsed),
520 status="aborted",
521 branch=original_branch,
522 new_head=original_head,
523 onto=active_state["onto"],
524 squash=active_state["squash"],
525 replayed=len(active_state["completed"]),
526 replayed_commit_ids=[],
527 conflicts=[],
528 )
529 print(json.dumps(result_payload))
530 else:
531 print(f"✅ Rebase aborted. HEAD restored to {original_head}.")
532 return
533
534 # --continue
535 if continue_:
536 if active_state is None:
537 print("❌ No rebase in progress. Nothing to continue.", file=sys.stderr)
538 raise SystemExit(ExitCode.USER_ERROR)
539
540 current_parent = (
541 active_state["completed"][-1]
542 if active_state["completed"]
543 else active_state["onto"]
544 )
545 orig_commit_id = active_state["remaining"][0] if active_state["remaining"] else ""
546 orig_commit = read_commit(root, orig_commit_id) if orig_commit_id else None
547
548 snap_result = plugin.snapshot(root)
549 manifest: Manifest = snap_result["files"]
550 manifest_dirs = directories_from_manifest(manifest)
551 snapshot_id = hash_snapshot(manifest, manifest_dirs)
552 committed_at = datetime.datetime.now(datetime.timezone.utc)
553 message = orig_commit.message if orig_commit else "rebase: continued"
554 _author = orig_commit.author if orig_commit else ""
555 new_commit_id = hash_commit(
556 parent_ids=[current_parent] if current_parent else [],
557 snapshot_id=snapshot_id,
558 message=message,
559 committed_at_iso=committed_at.isoformat(),
560 author=_author,
561 )
562 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=manifest_dirs))
563 new_commit = CommitRecord(
564 commit_id=new_commit_id,
565 branch=branch,
566 snapshot_id=snapshot_id,
567 message=message,
568 committed_at=committed_at,
569 parent_commit_id=current_parent if current_parent else None,
570 author=orig_commit.author if orig_commit else "",
571 )
572 write_commit(root, new_commit)
573 active_state["completed"].append(new_commit_id)
574 if active_state["remaining"]:
575 active_state["remaining"].pop(0)
576 save_rebase_state(root, active_state)
577
578 append_reflog(
579 root, branch,
580 old_id=current_parent,
581 new_id=new_commit_id,
582 author="user",
583 operation=f"rebase: continue — replayed {orig_commit_id if orig_commit_id else '?'}",
584 )
585
586 if not active_state["remaining"]:
587 try:
588 _write_branch_ref(root, branch, new_commit_id, expected_id=active_state["original_head"])
589 except RefConflictError as exc:
590 print(f"❌ {exc}", file=sys.stderr)
591 raise SystemExit(ExitCode.USER_ERROR)
592 clear_rebase_state(root)
593 if json_out:
594 result_payload = _RebaseResultJson(
595 **make_envelope(elapsed),
596 status="completed",
597 branch=branch,
598 new_head=new_commit_id,
599 onto=active_state["onto"],
600 squash=False,
601 replayed=len(active_state["completed"]),
602 replayed_commit_ids=list(active_state["completed"]),
603 conflicts=[],
604 )
605 print(json.dumps(result_payload))
606 else:
607 print(f"✅ Rebase complete. HEAD is now {new_commit_id}.")
608 return
609
610 clean = _run_replay_loop(
611 root, active_state, branch, plugin, domain, json_out, t0
612 )
613 if clean:
614 final_id = active_state["completed"][-1]
615 try:
616 _write_branch_ref(root, branch, final_id, expected_id=active_state["original_head"])
617 except RefConflictError as exc:
618 print(f"❌ {exc}", file=sys.stderr)
619 raise SystemExit(ExitCode.USER_ERROR)
620 clear_rebase_state(root)
621 if json_out:
622 result_payload = _RebaseResultJson(
623 **make_envelope(elapsed),
624 status="completed",
625 branch=branch,
626 new_head=final_id,
627 onto=active_state["onto"],
628 squash=False,
629 replayed=len(active_state["completed"]),
630 replayed_commit_ids=list(active_state["completed"]),
631 conflicts=[],
632 )
633 print(json.dumps(result_payload))
634 else:
635 print(f"✅ Rebase complete. HEAD is now {final_id}.")
636 return
637
638 # New rebase
639 if active_state is not None:
640 print("❌ Rebase in progress. Use --continue or --abort.", file=sys.stderr)
641 raise SystemExit(ExitCode.USER_ERROR)
642
643 if upstream is None:
644 print("❌ Provide an upstream branch or commit to rebase onto.", file=sys.stderr)
645 raise SystemExit(ExitCode.USER_ERROR)
646
647 head_commit_id = get_head_commit_id(root, branch)
648 if head_commit_id is None:
649 print("❌ Current branch has no commits.", file=sys.stderr)
650 raise SystemExit(ExitCode.USER_ERROR)
651
652 upstream_id = _resolve_ref_to_id(root, branch, upstream)
653 if upstream_id is None:
654 print(
655 f"❌ Upstream '{sanitize_display(upstream)}' not found.", file=sys.stderr
656 )
657 raise SystemExit(ExitCode.USER_ERROR)
658
659 if onto is not None:
660 onto_id = _resolve_ref_to_id(root, branch, onto)
661 if onto_id is None:
662 print(
663 f"❌ --onto '{sanitize_display(onto)}' not found.", file=sys.stderr
664 )
665 raise SystemExit(ExitCode.USER_ERROR)
666 else:
667 onto_id = upstream_id
668
669 merge_base_id = find_merge_base(root, head_commit_id, upstream_id)
670 stop_at = merge_base_id or ""
671
672 if head_commit_id == upstream_id or head_commit_id == onto_id:
673 if json_out:
674 result_payload = _RebaseResultJson(
675 **make_envelope(elapsed),
676 status="up_to_date",
677 branch=branch,
678 new_head=head_commit_id,
679 onto=onto_id,
680 squash=squash,
681 replayed=0,
682 replayed_commit_ids=[],
683 conflicts=[],
684 )
685 print(json.dumps(result_payload))
686 else:
687 print("Already up to date.")
688 return
689
690 commits_to_replay = collect_commits_to_replay(
691 root, stop_at, head_commit_id, max_commits=max_commits
692 )
693 if not commits_to_replay:
694 if json_out:
695 result_payload = _RebaseResultJson(
696 **make_envelope(elapsed),
697 status="up_to_date",
698 branch=branch,
699 new_head=head_commit_id,
700 onto=onto_id,
701 squash=squash,
702 replayed=0,
703 replayed_commit_ids=[],
704 conflicts=[],
705 )
706 print(json.dumps(result_payload))
707 else:
708 print("Already up to date.")
709 return
710
711 # --dry-run — show plan and exit
712 if dry_run:
713 if json_out:
714 commit_entries = [
715 _RebaseDryRunCommitJson(
716 commit_id=c.commit_id,
717 message=sanitize_display(c.message),
718 )
719 for c in commits_to_replay
720 ]
721 dry_payload = _RebaseDryRunJson(
722 **make_envelope(elapsed),
723 branch=branch,
724 onto=onto_id,
725 commits=commit_entries,
726 count=len(commits_to_replay),
727 squash=squash,
728 )
729 print(json.dumps(dry_payload))
730 else:
731 print(
732 f"Would rebase {len(commits_to_replay)} commit(s) "
733 f"onto {onto_id} (from {branch})"
734 )
735 for c in commits_to_replay:
736 print(f" {short_id(c.commit_id)} {sanitize_display(c.message)}")
737 return
738
739 if not json_out:
740 print(
741 f"Rebasing {len(commits_to_replay)} commit(s) "
742 f"onto {onto_id} (from {branch})"
743 )
744
745 # Squash mode
746 if squash:
747 current_parent = onto_id
748 squash_manifest: Manifest = {}
749
750 onto_commit = read_commit(root, onto_id)
751 if onto_commit:
752 onto_snap = read_snapshot(root, onto_commit.snapshot_id)
753 if onto_snap:
754 squash_manifest = dict(onto_snap.manifest)
755
756 conflict_occurred = False
757 conflict_paths: list[str] = []
758 for commit in commits_to_replay:
759 base_manifest: Manifest = {}
760 if commit.parent_commit_id:
761 pc = read_commit(root, commit.parent_commit_id)
762 if pc:
763 ps = read_snapshot(root, pc.snapshot_id)
764 if ps:
765 base_manifest = ps.manifest
766
767 theirs_snap = read_snapshot(root, commit.snapshot_id)
768 if theirs_snap is None:
769 print(
770 f"❌ Rebase aborted: snapshot {commit.snapshot_id} for "
771 f"commit {commit.commit_id} ({commit.message!r}) is missing "
772 "or corrupt. A squash with a missing snapshot would silently "
773 "delete all files from that commit. "
774 "Run `muse verify-pack` to audit the store.",
775 file=sys.stderr,
776 )
777 raise SystemExit(ExitCode.INTERNAL_ERROR)
778 theirs_manifest = theirs_snap.manifest
779
780 result = plugin.merge(
781 SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest)),
782 SnapshotManifest(files=squash_manifest, domain=domain, directories=directories_from_manifest(squash_manifest)),
783 SnapshotManifest(files=theirs_manifest, domain=domain, directories=directories_from_manifest(theirs_manifest)),
784 repo_root=root,
785 )
786 if not result.is_clean:
787 conflict_paths = sorted(result.conflicts)
788 if json_out:
789 result_payload = _RebaseResultJson(
790 **make_envelope(elapsed, exit_code=1),
791 status="conflict",
792 branch=branch,
793 new_head=None,
794 onto=onto_id,
795 squash=True,
796 replayed=0,
797 replayed_commit_ids=[],
798 conflicts=conflict_paths,
799 )
800 print(json.dumps(result_payload))
801 else:
802 print(
803 f"❌ Conflict during squash at {commit.commit_id}:",
804 file=sys.stderr,
805 )
806 for p in conflict_paths:
807 print(f" CONFLICT: {p}", file=sys.stderr)
808 print(
809 "Resolve conflicts and try again. "
810 "Squash does not support --continue.",
811 file=sys.stderr,
812 )
813 conflict_occurred = True
814 break
815 squash_manifest = result.merged["files"]
816
817 if conflict_occurred:
818 raise SystemExit(ExitCode.USER_ERROR)
819
820 apply_manifest(root, {}, squash_manifest)
821 squash_dirs = directories_from_manifest(squash_manifest)
822 snapshot_id = hash_snapshot(squash_manifest, squash_dirs)
823 committed_at = datetime.datetime.now(datetime.timezone.utc)
824 final_message = squash_message or commits_to_replay[-1].message
825 squash_author = commits_to_replay[0].author if commits_to_replay else ""
826 new_commit_id = hash_commit(
827 parent_ids=[onto_id],
828 snapshot_id=snapshot_id,
829 message=final_message,
830 committed_at_iso=committed_at.isoformat(),
831 author=squash_author,
832 )
833 write_snapshot(
834 root, SnapshotRecord(snapshot_id=snapshot_id, manifest=squash_manifest, directories=squash_dirs)
835 )
836 write_commit(
837 root,
838 CommitRecord(
839 commit_id=new_commit_id,
840 branch=branch,
841 snapshot_id=snapshot_id,
842 message=final_message,
843 committed_at=committed_at,
844 parent_commit_id=onto_id,
845 author=squash_author,
846 ),
847 )
848 try:
849 _write_branch_ref(root, branch, new_commit_id, expected_id=head_commit_id)
850 except RefConflictError as exc:
851 print(f"❌ {exc}", file=sys.stderr)
852 raise SystemExit(ExitCode.USER_ERROR)
853 append_reflog(
854 root, branch,
855 old_id=head_commit_id,
856 new_id=new_commit_id,
857 author="user",
858 operation=f"rebase --squash onto {onto_id}",
859 )
860 if json_out:
861 result_payload = _RebaseResultJson(
862 **make_envelope(elapsed),
863 status="completed",
864 branch=branch,
865 new_head=new_commit_id,
866 onto=onto_id,
867 squash=True,
868 replayed=len(commits_to_replay),
869 replayed_commit_ids=[new_commit_id],
870 conflicts=[],
871 )
872 print(json.dumps(result_payload))
873 else:
874 print(f"✅ Squash-rebase complete. HEAD is now {new_commit_id}.")
875 return
876
877 # Normal replay loop
878 state = RebaseState(
879 original_branch=branch,
880 original_head=head_commit_id,
881 onto=onto_id,
882 remaining=[c.commit_id for c in commits_to_replay],
883 completed=[],
884 squash=False,
885 )
886 save_rebase_state(root, state)
887
888 clean = _run_replay_loop(root, state, branch, plugin, domain, json_out, t0)
889
890 if clean:
891 final_id = state["completed"][-1] if state["completed"] else onto_id
892 try:
893 _write_branch_ref(root, branch, final_id, expected_id=head_commit_id)
894 except RefConflictError as exc:
895 print(f"❌ {exc}", file=sys.stderr)
896 raise SystemExit(ExitCode.USER_ERROR)
897 clear_rebase_state(root)
898 append_reflog(
899 root, branch,
900 old_id=head_commit_id,
901 new_id=final_id,
902 author="user",
903 operation=f"rebase: finished onto {onto_id}",
904 )
905 if json_out:
906 result_payload = _RebaseResultJson(
907 **make_envelope(elapsed),
908 status="completed",
909 branch=branch,
910 new_head=final_id,
911 onto=onto_id,
912 squash=False,
913 replayed=len(state["completed"]),
914 replayed_commit_ids=list(state["completed"]),
915 conflicts=[],
916 )
917 print(json.dumps(result_payload))
918 else:
919 print(f"✅ Rebase complete. HEAD is now {final_id}.")
File History 1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 72 days ago