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